From 2585f4ecfd8c7a2e5ab0dba304507c6b8d414718 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 3 Jul 2024 10:24:26 +0100 Subject: [PATCH] ios: ChatView performance improvements (#4353) * feat: Add synthesized hashable conformance to chat and API types (#4348) * UIKit ReverseList * ReverseList - manual layout updates for external state * Propagate ScrollModel; Disable async media width * Filter chat items * Remove UIKit menu wrapper * Make chat item width calculation synchronous (#4371) * Fix floating button regression * Improve filter performance * Fix page load the merged items exceed full page * Resolve iOS15 compabibility * Restore build config * Add page-up scroll; Fix same item decrementing unread counter multiple times * Fix: Chat not loading additional pages, if newest items are all merged and exceed page size * Minor * Fix item loading regression * Fix item loading regression 2 * Fix unread regression * refactor --------- Co-authored-by: Arturs Krumins Co-authored-by: Levitating Pineapple Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 18 +- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Views/Chat/ChatItem/CIImageView.swift | 13 +- .../Views/Chat/ChatItem/CIVideoView.swift | 24 +- .../Views/Chat/ChatItem/FramedItemView.swift | 22 +- .../Chat/ChatItem/FullScreenMediaView.swift | 6 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 35 +- apps/ios/Shared/Views/Chat/ChatView.swift | 673 ++++++++++-------- apps/ios/Shared/Views/Chat/ReverseList.swift | 270 +++++++ .../Shared/Views/ChatList/ChatListView.swift | 4 +- .../Shared/Views/Helpers/ContextMenu.swift | 112 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +- apps/ios/SimpleXChat/APITypes.swift | 136 ++-- apps/ios/SimpleXChat/ChatTypes.swift | 245 ++++--- 14 files changed, 921 insertions(+), 649 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ReverseList.swift delete mode 100644 apps/ios/Shared/Views/Helpers/ContextMenu.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a4651e1d42..542c7974e2 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -332,12 +332,12 @@ final class ChatModel: ObservableObject { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { if let i = getChatItemIndex(cItem) { - withAnimation { + withConditionalAnimation { _updateChatItem(at: i, with: cItem) } return false } else { - withAnimation(itemAnimation()) { + withConditionalAnimation(itemAnimation()) { var ci = cItem if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { ci.meta.itemStatus = status @@ -357,7 +357,7 @@ final class ChatModel: ObservableObject { func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { - withAnimation { + withConditionalAnimation { _updateChatItem(at: i, with: cItem) } } else if let status = status { @@ -512,11 +512,13 @@ final class ChatModel: ObservableObject { } func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) { - // update preview - decreaseUnreadCounter(cInfo) - // update current chat if chatId == cInfo.id, let i = getChatItemIndex(cItem) { - markChatItemRead_(i) + if reversedChatItems[i].isRcvNew { + // update current chat + markChatItemRead_(i) + // update preview + decreaseUnreadCounter(cInfo) + } } } @@ -723,7 +725,7 @@ struct NTFContactRequest { var chatId: String } -struct UnreadChatItemCounts { +struct UnreadChatItemCounts: Equatable { var totalBelow: Int var unreadBelow: Int } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 49152283ee..eb66071ef8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -309,8 +309,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { throw r } +let loadItemsPerPage = 50 + func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat { - let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search)) + let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) if case let .apiChat(_, chat) = r { return Chat.init(chat) } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index cfead635fe..5aacc335ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -13,10 +13,9 @@ struct CIImageView: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let chatItem: ChatItem - let image: String + var preview: UIImage? let maxWidth: CGFloat - @Binding var imgWidth: CGFloat? - @State var scrollProxy: ScrollViewProxy? + var imgWidth: CGFloat? @State private var showFullScreenImage = false var body: some View { @@ -25,15 +24,14 @@ struct CIImageView: View { if let uiImage = getLoadedImage(file) { imageView(uiImage) .fullScreenCover(isPresented: $showFullScreenImage) { - FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy) + FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage) } .onTapGesture { showFullScreenImage = true } .onChange(of: m.activeCallViewIsCollapsed) { _ in showFullScreenImage = false } - } else if let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { - imageView(uiImage) + } else if let preview { + imageView(preview) .onTapGesture { if let file = file { switch file.fileStatus { @@ -90,7 +88,6 @@ struct CIImageView: View { private func imageView(_ img: UIImage) -> some View { let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth - DispatchQueue.main.async { imgWidth = w } return ZStack(alignment: .topTrailing) { if img.imageData == nil { Image(uiImage: img) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index d84ad8f5fc..055a235970 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -15,14 +15,12 @@ struct CIVideoView: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme private let chatItem: ChatItem - private let image: String + private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @State private var videoPlaying: Bool = false private let maxWidth: CGFloat - @Binding private var videoWidth: CGFloat? - @State private var scrollProxy: ScrollViewProxy? - @State private var preview: UIImage? = nil + private var videoWidth: CGFloat? @State private var player: AVPlayer? @State private var fullPlayer: AVPlayer? @State private var url: URL? @@ -33,13 +31,12 @@ struct CIVideoView: View { @State private var fullScreenTimeObserver: Any? = nil @State private var publisher: AnyCancellable? = nil - init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding, scrollProxy: ScrollViewProxy?) { + init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) { self.chatItem = chatItem - self.image = image + self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth - self._videoWidth = videoWidth - self.scrollProxy = scrollProxy + self.videoWidth = videoWidth if let url = getLoadedVideo(chatItem.file) { let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet() self._urlDecrypted = State(initialValue: decrypted) @@ -49,10 +46,6 @@ struct CIVideoView: View { } self._url = State(initialValue: url) } - if let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { - self._preview = State(initialValue: uiImage) - } } var body: some View { @@ -63,9 +56,8 @@ struct CIVideoView: View { videoView(player, decrypted, file, preview, duration) } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { videoViewEncrypted(file, defaultPreview, duration) - } else if let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { - imageView(uiImage) + } else if let preview { + imageView(preview) .onTapGesture { if let file = file { switch file.fileStatus { @@ -152,7 +144,6 @@ struct CIVideoView: View { private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View { let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth - DispatchQueue.main.async { videoWidth = w } return ZStack(alignment: .topTrailing) { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) @@ -252,7 +243,6 @@ struct CIVideoView: View { private func imageView(_ img: UIImage) -> some View { let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth - DispatchQueue.main.async { videoWidth = w } return ZStack(alignment: .topTrailing) { Image(uiImage: img) .resizable() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b4cecf526..f8c5f3a4da 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -18,15 +18,16 @@ private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, o struct FramedItemView: View { @EnvironmentObject var m: ChatModel + @EnvironmentObject var scrollModel: ReverseListScrollModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat var chatItem: ChatItem + var preview: UIImage? @Binding var revealed: Bool var maxWidth: CGFloat = .infinity - @State var scrollProxy: ScrollViewProxy? = nil @State var msgWidth: CGFloat = 0 - @State var imgWidth: CGFloat? = nil - @State var videoWidth: CGFloat? = nil + var imgWidth: CGFloat? = nil + var videoWidth: CGFloat? = nil @State var metaColor = Color.secondary @State var showFullScreenImage = false @Binding var allowMenu: Bool @@ -58,10 +59,9 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) .onTapGesture { - if let proxy = scrollProxy, - let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { + if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { - proxy.scrollTo(ci.viewId, anchor: .bottom) + scrollModel.scrollToItem(id: ci.id) } } } @@ -84,6 +84,7 @@ struct FramedItemView: View { } } .background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme)) + .background(Color(.systemBackground)) .cornerRadius(18) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } @@ -114,8 +115,8 @@ struct FramedItemView: View { .padding(.bottom, 2) } else { switch (chatItem.content.msgContent) { - case let .image(text, image): - CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy) + case let .image(text, _): + CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -127,8 +128,8 @@ struct FramedItemView: View { } else { ciMsgContentView(chatItem) } - case let .video(text, image, duration): - CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy) + case let .video(text, _, duration): + CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -181,7 +182,6 @@ struct FramedItemView: View { .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) .overlay(DetermineWidth()) .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, colorScheme)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index 0e721acdcb..a80c5412b6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -13,12 +13,12 @@ import AVKit struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel + @EnvironmentObject var scrollModel: ReverseListScrollModel @State var chatItem: ChatItem @State var image: UIImage? @State var player: AVPlayer? = nil @State var url: URL? = nil @Binding var showView: Bool - @State var scrollProxy: ScrollViewProxy? @State private var showNext = false @State private var nextImage: UIImage? @State private var nextPlayer: AVPlayer? @@ -71,9 +71,7 @@ struct FullScreenMediaView: View { let w = abs(t.width) if t.height > 60 && t.height > w * 2 { showView = false - if let proxy = scrollProxy { - proxy.scrollTo(chatItem.viewId) - } + scrollModel.scrollToItem(id: chatItem.id) } else if w > 60 && w > abs(t.height) * 2 && !scrolling { let previous = t.width > 0 scrolling = true diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d580fb5f3e..a0c8254496 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -13,7 +13,6 @@ struct ChatItemView: View { @ObservedObject var chat: Chat var chatItem: ChatItem var maxWidth: CGFloat = .infinity - @State var scrollProxy: ScrollViewProxy? = nil @Binding var revealed: Bool @Binding var allowMenu: Bool @Binding var audioPlayer: AudioPlayer? @@ -24,7 +23,6 @@ struct ChatItemView: View { chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, - scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), @@ -34,7 +32,6 @@ struct ChatItemView: View { self.chat = chat self.chatItem = chatItem self.maxWidth = maxWidth - _scrollProxy = .init(initialValue: scrollProxy) _revealed = revealed _allowMenu = allowMenu _audioPlayer = audioPlayer @@ -62,7 +59,37 @@ struct ChatItemView: View { } private func framedItemView() -> some View { - FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + let preview = chatItem.content.msgContent + .flatMap { + switch $0 { + case let .image(_, image): image + case let .video(_, image, _): image + default: nil + } + } + .map { dropImagePrefix($0) } + .flatMap { Data(base64Encoded: $0) } + .flatMap { UIImage(data: $0) } + let adjustedMaxWidth = { + if let preview, preview.size.width <= preview.size.height { + maxWidth * 0.75 + } else { + maxWidth + } + }() + return FramedItemView( + chat: chat, + chatItem: chatItem, + preview: preview, + revealed: $revealed, + maxWidth: maxWidth, + imgWidth: adjustedMaxWidth, + videoWidth: adjustedMaxWidth, + allowMenu: $allowMenu, + audioPlayer: $audioPlayer, + playbackState: $playbackState, + playbackTime: $playbackTime + ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 27eb3bd653..7ab4913bf3 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat import SwiftyGif +import Combine private let memberImageSize: CGFloat = 34 @@ -19,6 +20,8 @@ struct ChatView: View { @Environment(\.presentationMode) var presentationMode @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat + @StateObject private var scrollModel = ReverseListScrollModel() + @StateObject private var floatingButtonModel = FloatingButtonModel() @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -26,11 +29,9 @@ struct ChatView: View { @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? - @State private var tableView: UITableView? @State private var loadingItems = false @State private var firstPage = false - @State private var itemsInView: Set = [] - @State private var scrollProxy: ScrollViewProxy? + @State private var revealedChatItem: ChatItem? @State private var searchMode = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -59,15 +60,10 @@ struct ChatView: View { searchToolbar() Divider() } - ZStack(alignment: .trailing) { + ZStack(alignment: .bottomTrailing) { chatItemsList() - if let proxy = scrollProxy { - floatingButtons(proxy) - } + floatingButtons(counts: floatingButtonModel.unreadChatItemCounts) } - - Spacer(minLength: 0) - connectingText() ComposeView( chat: chat, @@ -80,6 +76,7 @@ struct ChatView: View { .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) .onAppear { + loadChat(chat: chat) initChatView() } .onChange(of: chatModel.chatId) { cId in @@ -93,6 +90,15 @@ struct ChatView: View { dismiss() } } + .onChange(of: revealedChatItem) { _ in + NotificationCenter.postReverseListNeedsLayout() + } + .onChange(of: chatModel.reversedChatItems) { reversedChatItems in + if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 { + loadChatItems(chat.chatInfo) + } + } + .environmentObject(scrollModel) .onDisappear { VideoPlayerView.players.removeAll() if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { @@ -291,7 +297,6 @@ struct ChatView: View { searchMode = false searchFocussed = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - chatModel.reversedChatItems = [] loadChat(chat: chat) } } @@ -299,50 +304,49 @@ struct ChatView: View { .padding(.horizontal) .padding(.vertical, 8) } - + private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } + private func filtered(_ reversedChatItems: Array) -> Array { + reversedChatItems + .enumerated() + .filter { (index, chatItem) in + if let mergeCategory = chatItem.mergeCategory, index > .zero { + mergeCategory != reversedChatItems[index - 1].mergeCategory + } else { + true + } + } + .map { $0.element } + } + + private func chatItemsList() -> some View { let cInfo = chat.chatInfo + let mergedItems = filtered(chatModel.reversedChatItems) return GeometryReader { g in - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in - let voiceNoFrame = voiceWithoutFrame(ci) - let maxWidth = cInfo.chatType == .group - ? voiceNoFrame - ? (g.size.width - 28) - 42 - : (g.size.width - 28) * 0.84 - 42 - : voiceNoFrame - ? (g.size.width - 32) - : (g.size.width - 32) * 0.84 - chatItemView(ci, maxWidth) - .scaleEffect(x: 1, y: -1, anchor: .center) - .onAppear { - itemsInView.insert(ci.viewId) - loadChatItems(cInfo, ci, proxy) - if ci.isRcvNew { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) { - Task { - await apiMarkChatItemRead(cInfo, ci) - } - } - } - } - } - .onDisappear { - itemsInView.remove(ci.viewId) - } - } + ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in + let voiceNoFrame = voiceWithoutFrame(ci) + let maxWidth = cInfo.chatType == .group + ? voiceNoFrame + ? (g.size.width - 28) - 42 + : (g.size.width - 28) * 0.84 - 42 + : voiceNoFrame + ? (g.size.width - 32) + : (g.size.width - 32) * 0.84 + return chatItemView(ci, maxWidth) + .onAppear { + floatingButtonModel.appeared(viewId: ci.viewId) } - } - .onAppear { - scrollProxy = proxy - } + .onDisappear { + floatingButtonModel.disappeared(viewId: ci.viewId) + } + .id(ci.id) // Required to trigger `onAppear` on iOS15 + } loadPage: { + loadChatItems(cInfo) + } .onTapGesture { hideKeyboard() } .onChange(of: searchText) { _ in loadChat(chat: chat, search: searchText) @@ -352,14 +356,12 @@ struct ChatView: View { chat = c showChatInfoSheet = false loadChat(chat: c) - DispatchQueue.main.async { - scrollToBottom(proxy) - } } } - } + .onChange(of: chatModel.reversedChatItems) { _ in + floatingButtonModel.chatItemsChanged() + } } - .scaleEffect(x: 1, y: -1, anchor: .center) } @ViewBuilder private func connectingText() -> some View { @@ -375,10 +377,58 @@ struct ChatView: View { EmptyView() } } - - private func floatingButtons(_ proxy: ScrollViewProxy) -> some View { - let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView) - return VStack { + + class FloatingButtonModel: ObservableObject { + private enum Event { + case appeared(String) + case disappeared(String) + case chatItemsChanged + } + + @Published var unreadChatItemCounts: UnreadChatItemCounts + + private let events = PassthroughSubject() + private var bag = Set() + + init() { + unreadChatItemCounts = UnreadChatItemCounts( + totalBelow: .zero, + unreadBelow: .zero + ) + events + .receive(on: DispatchQueue.global(qos: .background)) + .scan(Set()) { itemsInView, event in + return switch event { + case let .appeared(viewId): + itemsInView.union([viewId]) + case let .disappeared(viewId): + itemsInView.subtracting([viewId]) + case .chatItemsChanged: + itemsInView + } + } + .throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true) + .map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) } + .removeDuplicates() + .assign(to: \.unreadChatItemCounts, on: self) + .store(in: &bag) + } + + func appeared(viewId: String) { + events.send(.appeared(viewId)) + } + + func disappeared(viewId: String) { + events.send(.disappeared(viewId)) + } + + func chatItemsChanged() { + events.send(.chatItemsChanged) + } + } + + private func floatingButtons(counts: UnreadChatItemCounts) -> some View { + VStack { let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow if unreadAbove > 0 { circleButton { @@ -386,13 +436,13 @@ struct ChatView: View { .font(.callout) .foregroundColor(.accentColor) } - .onTapGesture { scrollUp(proxy) } + .onTapGesture { + scrollModel.scrollToNextPage() + } .contextMenu { Button { - if let ci = chatModel.topItemInView(itemsInView: itemsInView) { - Task { - await markChatRead(chat, aboveItem: ci) - } + Task { + await markChatRead(chat) } } label: { Label("Mark read", systemImage: "checkmark") @@ -406,18 +456,22 @@ struct ChatView: View { .font(.callout) .foregroundColor(.accentColor) } - .onTapGesture { scrollToBottom(proxy) } + .onTapGesture { + if let latestUnreadItem = filtered(chatModel.reversedChatItems).last(where: { $0.isRcvNew }) { + scrollModel.scrollToItem(id: latestUnreadItem.id) + } + } } else if counts.totalBelow > 16 { circleButton { Image(systemName: "chevron.down") .foregroundColor(.accentColor) } - .onTapGesture { scrollToBottom(proxy) } + .onTapGesture { scrollModel.scrollToBottom() } } } .padding() } - + private func circleButton(_ content: @escaping () -> Content) -> some View { ZStack { Circle() @@ -426,7 +480,7 @@ struct ChatView: View { content() } } - + private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -456,7 +510,7 @@ struct ChatView: View { Label("Search", systemImage: "magnifyingglass") } } - + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { @@ -486,34 +540,45 @@ struct ChatView: View { } } - private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { - if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { + private func loadChatItems(_ cInfo: ChatInfo) { + Task { if loadingItems || firstPage { return } loadingItems = true - Task { - do { - let items = try await apiGetChatItems( + do { + var reversedPage = Array() + var chatItemsAvailable = true + // Load additional items until the page is +50 large after merging + while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { + let pagination: ChatPagination = + if let lastItem = reversedPage.last ?? chatModel.reversedChatItems.last { + .before(chatItemId: lastItem.id, count: loadItemsPerPage) + } else { + .last(count: loadItemsPerPage) + } + let chatItems = try await apiGetChatItems( type: cInfo.chatType, id: cInfo.apiId, - pagination: .before(chatItemId: firstItem.id, count: 50), + pagination: pagination, search: searchText ) - await MainActor.run { - if items.count == 0 { - firstPage = true - } else { - chatModel.reversedChatItems.append(contentsOf: items.reversed()) - } - loadingItems = false - } - } catch let error { - logger.error("apiGetChat error: \(responseError(error))") - await MainActor.run { loadingItems = false } + chatItemsAvailable = !chatItems.isEmpty + reversedPage.append(contentsOf: chatItems.reversed()) } + await MainActor.run { + if reversedPage.count == 0 { + firstPage = true + } else { + chatModel.reversedChatItems.append(contentsOf: reversedPage) + } + loadingItems = false + } + } catch let error { + logger.error("apiGetChat error: \(responseError(error))") + await MainActor.run { loadingItems = false } } } } - + @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { ChatItemWithMenu( chat: chat, @@ -522,6 +587,7 @@ struct ChatView: View { itemWidth: maxWidth, composeState: $composeState, selectedMember: $selectedMember, + revealedChatItem: $revealedChatItem, chatView: self ) } @@ -535,13 +601,13 @@ struct ChatView: View { @State var itemWidth: CGFloat @Binding var composeState: ComposeState @Binding var selectedMember: GMember? + @Binding var revealedChatItem: ChatItem? var chatView: ChatView @State private var deletingItem: ChatItem? = nil @State private var showDeleteMessage = false @State private var deletingItems: [Int64] = [] @State private var showDeleteMessages = false - @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var showForwardingSheet: Bool = false @@ -552,15 +618,14 @@ struct ChatView: View { @State private var playbackState: VoiceMessagePlaybackState = .noPlayback @State private var playbackTime: TimeInterval? + var revealed: Bool { chatItem == revealedChatItem } + var body: some View { - let (currIndex, nextItem) = m.getNextChatItem(chatItem) + let (currIndex, _) = m.getNextChatItem(chatItem) let ciCategory = chatItem.mergeCategory - if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) { - // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView - ZStack {} // scroll doesn't work if it's EmptyView() - } else { - let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) - let range = itemsRange(currIndex, prevHidden) + let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) + let range = itemsRange(currIndex, prevHidden) + Group { if revealed, let range = range { let items = Array(zip(Array(range), m.reversedChatItems[range])) ForEach(items, id: \.1.viewId) { (i, ci) in @@ -568,11 +633,26 @@ struct ChatView: View { chatItemView(ci, nil, prev) } } else { - // Switch branches just to work around context menu problem when 'revealed' changes but size of item isn't - if revealed { - chatItemView(chatItem, range, prevItem) - } else { - chatItemView(chatItem, range, prevItem) + chatItemView(chatItem, range, prevItem) + } + } + .onAppear { + markRead( + chatItems: range.flatMap { m.reversedChatItems[$0] } + ?? [chatItem] + ) + } + } + + private func markRead(chatItems: Array.SubSequence) { + let unreadItems = chatItems.filter { $0.isRcvNew } + if unreadItems.isEmpty { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + if m.chatId == chat.chatInfo.id { + Task { + for unreadItem in unreadItems { + await apiMarkChatItemRead(chat.chatInfo, unreadItem) + } } } } @@ -645,24 +725,18 @@ struct ChatView: View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - let uiMenu: Binding = Binding( - get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) }, - set: { _ in } - ) - VStack(alignment: alignment.horizontal, spacing: 3) { ChatItemView( chat: chat, chatItem: ci, maxWidth: maxWidth, - scrollProxy: chatView.scrollProxy, - revealed: $revealed, + revealed: .constant(revealed), allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime ) - .uiKitContextMenu(hasImageOrVideo: ci.content.msgContent?.isImageOrVideo == true, maxWidth: maxWidth, itemWidth: $itemWidth, menu: uiMenu, allowMenu: $allowMenu) + .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } .accessibilityLabel("") if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { chatItemReactions(ci) @@ -746,149 +820,152 @@ struct ChatView: View { } } - private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> [UIMenuElement] { - var menu: [UIMenuElement] = [] + @ViewBuilder + private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { - let rs = allReactions(ci) if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, - rs.count > 0 { - var rm: UIMenu - if #available(iOS 16, *) { - var children: [UIMenuElement] = Array(rs.prefix(topReactionsCount(rs))) - if let sm = reactionUIMenu(rs) { - children.append(sm) - } - rm = UIMenu(title: "", options: .displayInline, children: children) - rm.preferredElementSize = .small - } else { - rm = reactionUIMenuPreiOS16(rs) - } - menu.append(rm) + availableReactions.count > 0 { + reactionsGroup } if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote { - menu.append(replyUIAction(ci)) + replyButton } let fileSource = getLoadedFileSource(ci.file) let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false } let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists) if copyAndShareAllowed { - menu.append(shareUIAction(ci)) - menu.append(copyUIAction(ci)) + shareButton(ci) + copyButton(ci) } if let fileSource = fileSource, fileExists { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { - menu.append(saveFileAction(fileSource)) + saveButton(file: fileSource) } else { - menu.append(saveImageAction(image)) + saveButton(image: image) } } else { - menu.append(saveFileAction(fileSource)) + saveButton(file: fileSource) } } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { - menu.append(downloadFileAction(file)) + downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { - menu.append(editAction(ci)) + editButton(chatItem) } if ci.meta.itemDeleted == nil && (ci.file == nil || (fileSource != nil && fileExists)) && !ci.isLiveDummy && !live { - menu.append(forwardUIAction(ci)) + forwardButton } if !ci.isLiveDummy { - menu.append(viewInfoUIAction(ci)) + viewInfoButton(ci) } if revealed { - menu.append(hideUIAction()) + hideButton() } if ci.meta.itemDeleted == nil && !ci.localNote, let file = ci.file, let cancelAction = file.cancelAction { - menu.append(cancelFileUIAction(file.fileId, cancelAction)) + cancelFileButton(file.fileId, cancelAction) } if !live || !ci.meta.isLive { - menu.append(deleteUIAction(ci)) + deleteButton(ci) } if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { - menu.append(moderateUIAction(ci, groupInfo)) + moderateButton(ci, groupInfo) } } else if ci.meta.itemDeleted != nil { if revealed { - menu.append(hideUIAction()) + hideButton() } else if !ci.isDeletedContent { - menu.append(revealUIAction()) + revealButton(ci) } else if range != nil { - menu.append(expandUIAction()) + expandButton() } - menu.append(viewInfoUIAction(ci)) - menu.append(deleteUIAction(ci)) + viewInfoButton(ci) + deleteButton(ci) } else if ci.isDeletedContent { - menu.append(viewInfoUIAction(ci)) - menu.append(deleteUIAction(ci)) + viewInfoButton(ci) + deleteButton(ci) } else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) { - menu.append(revealed ? shrinkUIAction() : expandUIAction()) - menu.append(deleteUIAction(ci)) + if revealed { shrinkButton() } else { expandButton() } + deleteButton(ci) } else if ci.showLocalDelete { - menu.append(deleteUIAction(ci)) + deleteButton(ci) + } else { + EmptyView() } - return menu } - - private func replyUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Reply", comment: "chat item action"), - image: UIImage(systemName: "arrowshape.turn.up.left") - ) { _ in + + var replyButton: Button { + Button { withAnimation { if composeState.editing { - composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + composeState = ComposeState(contextItem: .quotedItem(chatItem: chatItem)) } else { - composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + composeState = composeState.copy(contextItem: .quotedItem(chatItem: chatItem)) } } + } label: { + Label( + NSLocalizedString("Reply", comment: "chat item action"), + systemImage: "arrowshape.turn.up.left" + ) + } + } + + var forwardButton: Button { + Button { + showForwardingSheet = true + } label: { + Label( + NSLocalizedString("Forward", comment: "chat item action"), + systemImage: "arrowshape.turn.up.forward" + ) + } + } + + private var reactionsGroup: some View { + if #available(iOS 16.4, *) { + return ControlGroup { + if availableReactions.count > 4 { + reactions(till: 3) + Menu { + reactions(from: 3) + } label: { + Image(systemName: "ellipsis") + } + } else { reactions() } + }.controlGroupStyle(.compactMenu) + } else { + return Menu { + reactions() + } label: { + Label( + NSLocalizedString("React…", comment: "chat item menu"), + systemImage: "face.smiling" + ) + } } } - private func forwardUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Forward", comment: "chat item action"), - image: UIImage(systemName: "arrowshape.turn.up.forward") - ) { _ in - showForwardingSheet = true + func reactions(from: Int? = nil, till: Int? = nil) -> some View { + ForEach(availableReactions[(from ?? .zero)..<(till ?? availableReactions.count)]) { reaction in + Button(reaction.text) { + setReaction(chatItem, add: true, reaction: reaction) + } } } - private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu { - UIMenu( - title: NSLocalizedString("React…", comment: "chat item menu"), - image: UIImage(systemName: "face.smiling"), - children: rs - ) - } - - @available(iOS 16.0, *) - private func reactionUIMenu(_ rs: [UIAction]) -> UIMenu? { - var children = rs - children.removeFirst(min(rs.count, topReactionsCount(rs))) - if children.count == 0 { return nil } - return UIMenu( - title: "", - image: UIImage(systemName: "ellipsis"), - children: children - ) - } - - private func allReactions(_ ci: ChatItem) -> [UIAction] { - MsgReaction.values.compactMap { r in - ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) - ? nil - : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) } - } - } - - private func topReactionsCount(_ rs: [UIAction]) -> Int { - rs.count > 4 ? 3 : 4 + /// Reactions, which has not been used yet + private var availableReactions: Array { + MsgReaction.values + .filter { reaction in + !chatItem.reactions.contains { + $0.userReacted && $0.reaction == reaction + } + } } private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) { @@ -911,24 +988,23 @@ struct ChatView: View { } } - private func shareUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Share", comment: "chat item action"), - image: UIImage(systemName: "square.and.arrow.up") - ) { _ in + private func shareButton(_ ci: ChatItem) -> Button { + Button { var shareItems: [Any] = [ci.content.text] if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { shareItems.append(image) } showShareSheet(items: shareItems) + } label: { + Label( + NSLocalizedString("Share", comment: "chat item action"), + systemImage: "square.and.arrow.up" + ) } } - - private func copyUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Copy", comment: "chat item action"), - image: UIImage(systemName: "doc.on.doc") - ) { _ in + + private func copyButton(_ ci: ChatItem) -> Button { + Button { if case let .image(text, _) = ci.content.msgContent, text == "", let image = getLoadedImage(ci.file) { @@ -936,57 +1012,64 @@ struct ChatView: View { } else { UIPasteboard.general.string = ci.content.text } - } - } - - private func saveImageAction(_ image: UIImage) -> UIAction { - UIAction( - title: NSLocalizedString("Save", comment: "chat item action"), - image: UIImage(systemName: "square.and.arrow.down") - ) { _ in - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - } - } - - private func saveFileAction(_ fileSource: CryptoFile) -> UIAction { - UIAction( - title: NSLocalizedString("Save", comment: "chat item action"), - image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open") - ) { _ in - saveCryptoFile(fileSource) + } label: { + Label("Copy", systemImage: "doc.on.doc") } } - private func downloadFileAction(_ file: CIFile) -> UIAction { - UIAction( - title: NSLocalizedString("Download", comment: "chat item action"), - image: UIImage(systemName: "arrow.down.doc") - ) { _ in + func saveButton(image: UIImage) -> Button { + Button { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } label: { + Label( + NSLocalizedString("Save", comment: "chat item action"), + systemImage: "square.and.arrow.down" + ) + } + } + + func saveButton(file: CryptoFile) -> Button { + Button { + saveCryptoFile(file) + } label: { + Label( + NSLocalizedString("Save", comment: "chat item action"), + systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open" + ) + } + } + + func downloadButton(file: CIFile) -> Button { + Button { Task { logger.debug("ChatView downloadFileAction, in Task") if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) } } + } label: { + Label( + NSLocalizedString("Download", comment: "chat item action"), + systemImage: "arrow.down.doc" + ) } } - private func editAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Edit", comment: "chat item action"), - image: UIImage(systemName: "square.and.pencil") - ) { _ in + private func editButton(_ ci: ChatItem) -> Button { + Button { withAnimation { composeState = ComposeState(editingItem: ci) } + } label: { + Label( + NSLocalizedString("Edit", comment: "chat item action"), + systemImage: "square.and.pencil" + ) } } - private func viewInfoUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Info", comment: "chat item action"), - image: UIImage(systemName: "info.circle") - ) { _ in + private func viewInfoButton(_ ci: ChatItem) -> Button { + Button { Task { do { let cInfo = chat.chatInfo @@ -1002,15 +1085,16 @@ struct ChatView: View { } await MainActor.run { showChatItemInfoSheet = true } } + } label: { + Label( + NSLocalizedString("Info", comment: "chat item action"), + systemImage: "info.circle" + ) } } - private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction { - return UIAction( - title: cancelAction.uiAction, - image: UIImage(systemName: "xmark"), - attributes: [.destructive] - ) { _ in + private func cancelFileButton(_ fileId: Int64, _ cancelAction: CancelAction) -> Button { + Button { AlertManager.shared.showAlert(Alert( title: Text(cancelAction.alert.title), message: Text(cancelAction.alert.message), @@ -1023,26 +1107,29 @@ struct ChatView: View { }, secondaryButton: .cancel() )) + } label: { + Label( + cancelAction.uiAction, + systemImage: "xmark" + ) } } - private func hideUIAction() -> UIAction { - UIAction( - title: NSLocalizedString("Hide", comment: "chat item action"), - image: UIImage(systemName: "eye.slash") - ) { _ in - withAnimation { - revealed = false + private func hideButton() -> Button { + Button { + withConditionalAnimation { + revealedChatItem = nil } + } label: { + Label( + NSLocalizedString("Hide", comment: "chat item action"), + systemImage: "eye.slash" + ) } } - - private func deleteUIAction(_ ci: ChatItem) -> UIAction { - UIAction( - title: NSLocalizedString("Delete", comment: "chat item action"), - image: UIImage(systemName: "trash"), - attributes: [.destructive] - ) { _ in + + private func deleteButton(_ ci: ChatItem) -> Button { + Button(role: .destructive) { if !revealed, let currIndex = m.getChatItemIndex(ci), let ciCategory = ci.mergeCategory { @@ -1062,6 +1149,11 @@ struct ChatView: View { showDeleteMessage = true deletingItem = ci } + } label: { + Label( + NSLocalizedString("Delete", comment: "chat item action"), + systemImage: "trash" + ) } } @@ -1075,12 +1167,8 @@ struct ChatView: View { } } - private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction { - UIAction( - title: NSLocalizedString("Moderate", comment: "chat item action"), - image: UIImage(systemName: "flag"), - attributes: [.destructive] - ) { _ in + private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { + Button(role: .destructive) { AlertManager.shared.showAlert(Alert( title: Text("Delete member message?"), message: Text( @@ -1094,39 +1182,50 @@ struct ChatView: View { }, secondaryButton: .cancel() )) + } label: { + Label( + NSLocalizedString("Moderate", comment: "chat item action"), + systemImage: "flag" + ) } } - private func revealUIAction() -> UIAction { - UIAction( - title: NSLocalizedString("Reveal", comment: "chat item action"), - image: UIImage(systemName: "eye") - ) { _ in - withAnimation { - revealed = true + private func revealButton(_ ci: ChatItem) -> Button { + Button { + withConditionalAnimation { + revealedChatItem = ci } + } label: { + Label( + NSLocalizedString("Reveal", comment: "chat item action"), + systemImage: "eye" + ) } } - private func expandUIAction() -> UIAction { - UIAction( - title: NSLocalizedString("Expand", comment: "chat item action"), - image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down") - ) { _ in - withAnimation { - revealed = true + private func expandButton() -> Button { + Button { + withConditionalAnimation { + revealedChatItem = chatItem } + } label: { + Label( + NSLocalizedString("Expand", comment: "chat item action"), + systemImage: "arrow.up.and.line.horizontal.and.arrow.down" + ) } } - private func shrinkUIAction() -> UIAction { - UIAction( - title: NSLocalizedString("Hide", comment: "chat item action"), - image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up") - ) { _ in - withAnimation { - revealed = false + private func shrinkButton() -> Button { + Button { + withConditionalAnimation { + revealedChatItem = nil } + } label: { + Label ( + NSLocalizedString("Hide", comment: "chat item action"), + systemImage: "arrow.down.and.line.horizontal.and.arrow.up" + ) } } @@ -1205,18 +1304,6 @@ struct ChatView: View { } } } - - private func scrollToBottom(_ proxy: ScrollViewProxy) { - if let ci = chatModel.reversedChatItems.first { - withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } - } - } - - private func scrollUp(_ proxy: ScrollViewProxy) { - if let ci = chatModel.topItemInView(itemsInView: itemsInView) { - withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } - } - } } struct ToggleNtfsButton: View { diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift new file mode 100644 index 0000000000..f795b96b03 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -0,0 +1,270 @@ +// +// ReverseList.swift +// SimpleX (iOS) +// +// Created by Levitating Pineapple on 11/06/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import Combine + +/// A List, which displays it's items in reverse order - from bottom to top +struct ReverseList: UIViewControllerRepresentable { + + let items: Array + + @Binding var scrollState: ReverseListScrollModel.State + + /// Closure, that returns user interface for a given item + let content: (Item) -> Content + + let loadPage: () -> Void + + func makeUIViewController(context: Context) -> Controller { + Controller(representer: self) + } + + func updateUIViewController(_ controller: Controller, context: Context) { + if case let .scrollingTo(destination) = scrollState, !items.isEmpty { + switch destination { + case .nextPage: + controller.scrollToNextPage() + case let .item(id): + controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) + case .bottom: + controller.scroll(to: .zero, position: .top) + } + } else { + controller.update(items: items) + } + } + + /// Controller, which hosts SwiftUI cells + class Controller: UITableViewController { + private enum Section { case main } + private let representer: ReverseList + private var dataSource: UITableViewDiffableDataSource! + private var itemCount: Int = .zero + private var bag = Set() + + init(representer: ReverseList) { + self.representer = representer + super.init(style: .plain) + + // 1. Style + tableView.separatorStyle = .none + tableView.transform = .verticalFlip + + // 2. Register cells + if #available(iOS 16.0, *) { + tableView.register( + UITableViewCell.self, + forCellReuseIdentifier: cellReuseId + ) + } else { + tableView.register( + HostingCell.self, + forCellReuseIdentifier: cellReuseId + ) + } + + // 3. Configure data source + self.dataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { (tableView, indexPath, item) -> UITableViewCell? in + if indexPath.item > self.itemCount - 8, self.itemCount > 8 { + self.representer.loadPage() + } + let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) + if #available(iOS 16.0, *) { + cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) } + .margins(.all, .zero) + .minSize(height: 1) // Passing zero will result in system default of 44 points being used + } else { + if let cell = cell as? HostingCell { + cell.set(content: self.representer.content(item), parent: self) + } else { + fatalError("Unexpected Cell Type for: \(item)") + } + } + cell.transform = .verticalFlip + cell.selectionStyle = .none + return cell + } + + // 4. External state changes will require manual layout updates + NotificationCenter.default + .addObserver( + self, + selector: #selector(updateLayout), + name: notificationName, + object: nil + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + deinit { NotificationCenter.default.removeObserver(self) } + + @objc private func updateLayout() { + if #available(iOS 16.0, *) { + tableView.setNeedsLayout() + tableView.layoutIfNeeded() + } else { + tableView.reloadData() + } + } + + /// Hides keyboard, when user begins to scroll. + /// Equivalent to `.scrollDismissesKeyboard(.immediately)` + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + UIApplication.shared + .sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + + /// Scrolls up + func scrollToNextPage() { + tableView.setContentOffset( + CGPoint( + x: tableView.contentOffset.x, + y: tableView.contentOffset.y + tableView.bounds.height + ), + animated: true + ) + Task { representer.scrollState = .atDestination } + } + + /// Scrolls to Item at index path + /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` + func scroll(to index: Int?, position: UITableView.ScrollPosition) { + if let index { + var animated = false + if #available(iOS 16.0, *) { + animated = true + } + tableView.scrollToRow( + at: IndexPath(row: index, section: .zero), + at: position, + animated: animated + ) + Task { representer.scrollState = .atDestination } + } + } + + func update(items: Array) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items) + dataSource.defaultRowAnimation = .none + dataSource.apply( + snapshot, + animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1 + ) + itemCount = items.count + } + } + + /// `UIHostingConfiguration` back-port for iOS14 and iOS15 + /// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController` + private final class HostingCell: UITableViewCell { + private let hostingController = UIHostingController(rootView: nil) + + /// Updates content of the cell + /// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/ + func set(content: Hosted, parent: UIViewController) { + hostingController.rootView = content + if let hostingView = hostingController.view { + hostingView.invalidateIntrinsicContentSize() + if hostingController.parent != parent { parent.addChild(hostingController) } + if !contentView.subviews.contains(hostingController.view) { + contentView.addSubview(hostingController.view) + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.leadingAnchor + .constraint(equalTo: contentView.leadingAnchor), + hostingView.trailingAnchor + .constraint(equalTo: contentView.trailingAnchor), + hostingView.topAnchor + .constraint(equalTo: contentView.topAnchor), + hostingView.bottomAnchor + .constraint(equalTo: contentView.bottomAnchor) + ]) + } + if hostingController.parent != parent { hostingController.didMove(toParent: parent) } + } else { + fatalError("Hosting View not loaded \(hostingController)") + } + } + + override func prepareForReuse() { + super.prepareForReuse() + hostingController.rootView = nil + } + } +} + +/// Manages ``ReverseList`` scrolling +class ReverseListScrollModel: ObservableObject { + /// Represents Scroll State of ``ReverseList`` + enum State: Equatable { + enum Destination: Equatable { + case nextPage + case item(Item.ID) + case bottom + } + + case scrollingTo(Destination) + case atDestination + } + + @Published var state: State = .atDestination + + func scrollToNextPage() { + state = .scrollingTo(.nextPage) + } + + func scrollToBottom() { + state = .scrollingTo(.bottom) + } + + func scrollToItem(id: Item.ID) { + state = .scrollingTo(.item(id)) + } +} + +fileprivate let cellReuseId = "hostingCell" + +fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout") + +fileprivate extension CGAffineTransform { + /// Transform that vertically flips the view, preserving it's location + static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1) +} + +extension NotificationCenter { + static func postReverseListNeedsLayout() { + NotificationCenter.default.post( + name: notificationName, + object: nil + ) + } +} + +/// Disable animation on iOS 15 +func withConditionalAnimation( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + if #available(iOS 16.0, *) { + try withAnimation(animation, body) + } else { + try body() + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6bf63bb2e3..e39d39293a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -220,9 +220,7 @@ struct ChatListView: View { @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { - ChatView(chat: chat).onAppear { - loadChat(chat: chat) - } + ChatView(chat: chat) } } diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift deleted file mode 100644 index 9504d919ef..0000000000 --- a/apps/ios/Shared/Views/Helpers/ContextMenu.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// ContextMenu2.swift -// SimpleX (iOS) -// -// Created by Evgeny on 09/08/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import Foundation -import UIKit -import SwiftUI - -extension View { - func uiKitContextMenu(hasImageOrVideo: Bool, maxWidth: CGFloat, itemWidth: Binding, menu: Binding, allowMenu: Binding) -> some View { - Group { - if allowMenu.wrappedValue { - if hasImageOrVideo { - InteractionView(content: - self.environmentObject(ChatModel.shared) - .overlay(DetermineWidthImageVideoItem()) - .onPreferenceChange(DetermineWidthImageVideoItem.Key.self) { itemWidth.wrappedValue = $0 == 0 ? maxWidth : $0 } - , maxWidth: maxWidth, itemWidth: itemWidth, menu: menu) - .frame(maxWidth: itemWidth.wrappedValue) - } else { - InteractionView(content: self.environmentObject(ChatModel.shared), maxWidth: maxWidth, itemWidth: itemWidth, menu: menu) - .fixedSize(horizontal: true, vertical: false) - } - } else { - self - } - } - } -} - -private class HostingViewHolder: UIView { - var contentSize: CGSize = CGSizeMake(0, 0) - override var intrinsicContentSize: CGSize { get { contentSize } } -} - -struct InteractionView: UIViewRepresentable { - let content: Content - var maxWidth: CGFloat - var itemWidth: Binding - @Binding var menu: UIMenu - - func makeUIView(context: Context) -> UIView { - let view = HostingViewHolder() - view.backgroundColor = .clear - let hostView = UIHostingController(rootView: content) - view.contentSize = hostView.view.intrinsicContentSize - hostView.view.translatesAutoresizingMaskIntoConstraints = false - let constraints = [ - hostView.view.topAnchor.constraint(equalTo: view.topAnchor), - hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor), - hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor) - ] - view.addSubview(hostView.view) - view.addConstraints(constraints) - view.layer.cornerRadius = 18 - hostView.view.layer.cornerRadius = 18 - let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator) - view.addInteraction(menuInteraction) - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - let was = (uiView as! HostingViewHolder).contentSize - (uiView as! HostingViewHolder).contentSize = uiView.subviews[0].sizeThatFits(CGSizeMake(itemWidth.wrappedValue, .infinity)) - if was != (uiView as! HostingViewHolder).contentSize { - uiView.invalidateIntrinsicContentSize() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UIContextMenuInteractionDelegate { - let parent: InteractionView - - init(_ parent: InteractionView) { - self.parent = parent - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - UIContextMenuConfiguration( - identifier: nil, - previewProvider: nil, - actionProvider: { [weak self] _ in - guard let self = self else { return nil } - return self.parent.menu - } - ) - } - - // func contextMenuInteraction( - // _ interaction: UIContextMenuInteraction, - // willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, - // animator: UIContextMenuInteractionCommitAnimating - // ) { - // animator.addCompletion { - // print("user tapped") - // } - // } - } -} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 43aa79e0a9..057d6cccbe 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; - 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; }; 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; @@ -186,6 +185,7 @@ 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; + CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -267,7 +267,6 @@ 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; - 5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = ""; }; 5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; @@ -483,6 +482,7 @@ 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDevicePicker.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; + CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -591,6 +591,7 @@ 5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, + CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, @@ -651,7 +652,6 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */, 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */, 5C6BA666289BD954009B8ECC /* DismissSheets.swift */, - 5C00164328A26FBC0094D739 /* ContextMenu.swift */, 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */, 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */, 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */, @@ -1134,6 +1134,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */, 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, @@ -1176,7 +1177,6 @@ 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, - 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */, 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9ffba5380f..1ab2e2b575 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1010,20 +1010,20 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } -public enum ConnectionPlan: Decodable { +public enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) case groupLink(groupLinkPlan: GroupLinkPlan) } -public enum InvitationLinkPlan: Decodable { +public enum InvitationLinkPlan: Decodable, Hashable { case ok case ownLink case connecting(contact_: Contact?) case known(contact: Contact) } -public enum ContactAddressPlan: Decodable { +public enum ContactAddressPlan: Decodable, Hashable { case ok case ownLink case connectingConfirmReconnect @@ -1032,7 +1032,7 @@ public enum ContactAddressPlan: Decodable { case contactViaAddress(contact: Contact) } -public enum GroupLinkPlan: Decodable { +public enum GroupLinkPlan: Decodable, Hashable { case ok case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect @@ -1040,13 +1040,13 @@ public enum GroupLinkPlan: Decodable { case known(groupInfo: GroupInfo) } -struct NewUser: Encodable { +struct NewUser: Encodable, Hashable { var profile: Profile? var sameServers: Bool var pastTimestamp: Bool } -public enum ChatPagination { +public enum ChatPagination: Hashable { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) @@ -1268,7 +1268,7 @@ public struct ServerAddress: Decodable { ) } -public struct NetCfg: Codable, Equatable { +public struct NetCfg: Codable, Equatable, Hashable { public var socksProxy: String? = nil var socksMode: SocksMode = .always public var hostMode: HostMode = .publicHost @@ -1314,18 +1314,18 @@ public struct NetCfg: Codable, Equatable { public var enableKeepAlive: Bool { tcpKeepAlive != nil } } -public enum HostMode: String, Codable { +public enum HostMode: String, Codable, Hashable { case onionViaSocks case onionHost = "onion" case publicHost = "public" } -public enum SocksMode: String, Codable { +public enum SocksMode: String, Codable, Hashable { case always = "always" case onion = "onion" } -public enum SMPProxyMode: String, Codable { +public enum SMPProxyMode: String, Codable, Hashable { case always = "always" case unknown = "unknown" case unprotected = "unprotected" @@ -1345,7 +1345,7 @@ public enum SMPProxyMode: String, Codable { public static let values: [SMPProxyMode] = [.always, .unknown, .unprotected, .never] } -public enum SMPProxyFallback: String, Codable { +public enum SMPProxyFallback: String, Codable, Hashable { case allow = "allow" case allowProtected = "allowProtected" case prohibit = "prohibit" @@ -1363,7 +1363,7 @@ public enum SMPProxyFallback: String, Codable { public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit] } -public enum OnionHosts: String, Identifiable { +public enum OnionHosts: String, Identifiable, Hashable { case no case prefer case require @@ -1397,7 +1397,7 @@ public enum OnionHosts: String, Identifiable { public static let values: [OnionHosts] = [.no, .prefer, .require] } -public enum TransportSessionMode: String, Codable, Identifiable { +public enum TransportSessionMode: String, Codable, Identifiable, Hashable { case user case entity @@ -1413,7 +1413,7 @@ public enum TransportSessionMode: String, Codable, Identifiable { public static let values: [TransportSessionMode] = [.user, .entity] } -public struct KeepAliveOpts: Codable, Equatable { +public struct KeepAliveOpts: Codable, Equatable, Hashable { public var keepIdle: Int // seconds public var keepIntvl: Int // seconds public var keepCnt: Int // times @@ -1421,7 +1421,7 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } -public enum NetworkStatus: Decodable, Equatable { +public enum NetworkStatus: Decodable, Equatable, Hashable { case unknown case connected case disconnected @@ -1459,12 +1459,12 @@ public enum NetworkStatus: Decodable, Equatable { } } -public struct ConnNetworkStatus: Decodable { +public struct ConnNetworkStatus: Decodable, Hashable { public var agentConnId: String public var networkStatus: NetworkStatus } -public struct ChatSettings: Codable { +public struct ChatSettings: Codable, Hashable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? public var favorite: Bool @@ -1478,13 +1478,13 @@ public struct ChatSettings: Codable { public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) } -public enum MsgFilter: String, Codable { +public enum MsgFilter: String, Codable, Hashable { case none case all case mentions } -public struct UserMsgReceiptSettings: Codable { +public struct UserMsgReceiptSettings: Codable, Hashable { public var enable: Bool public var clearOverrides: Bool @@ -1494,7 +1494,7 @@ public struct UserMsgReceiptSettings: Codable { } } -public struct ConnectionStats: Decodable { +public struct ConnectionStats: Decodable, Hashable { public var connAgentVersion: Int public var rcvQueuesInfo: [RcvQueueInfo] public var sndQueuesInfo: [SndQueueInfo] @@ -1510,30 +1510,30 @@ public struct ConnectionStats: Decodable { } } -public struct RcvQueueInfo: Codable { +public struct RcvQueueInfo: Codable, Hashable { public var rcvServer: String public var rcvSwitchStatus: RcvSwitchStatus? public var canAbortSwitch: Bool } -public enum RcvSwitchStatus: String, Codable { +public enum RcvSwitchStatus: String, Codable, Hashable { case switchStarted = "switch_started" case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" } -public struct SndQueueInfo: Codable { +public struct SndQueueInfo: Codable, Hashable { public var sndServer: String public var sndSwitchStatus: SndSwitchStatus? } -public enum SndSwitchStatus: String, Codable { +public enum SndSwitchStatus: String, Codable, Hashable { case sendingQKEY = "sending_qkey" case sendingQTEST = "sending_qtest" } -public enum QueueDirection: String, Decodable { +public enum QueueDirection: String, Decodable, Hashable { case rcv case snd } @@ -1557,7 +1557,7 @@ public enum RatchetSyncState: String, Decodable { case agreed } -public struct UserContactLink: Decodable { +public struct UserContactLink: Decodable, Hashable { public var connReqContact: String public var autoAccept: AutoAccept? @@ -1571,7 +1571,7 @@ public struct UserContactLink: Decodable { } } -public struct AutoAccept: Codable { +public struct AutoAccept: Codable, Hashable { public var acceptIncognito: Bool public var autoReply: MsgContent? @@ -1593,7 +1593,7 @@ public protocol SelectableItem: Hashable, Identifiable { static var values: [Self] { get } } -public struct DeviceToken: Decodable { +public struct DeviceToken: Decodable, Hashable { var pushProvider: PushProvider var token: String @@ -1607,12 +1607,12 @@ public struct DeviceToken: Decodable { } } -public enum PushEnvironment: String { +public enum PushEnvironment: String, Hashable { case development case production } -public enum PushProvider: String, Decodable { +public enum PushProvider: String, Decodable, Hashable { case apns_dev case apns_prod @@ -1626,7 +1626,7 @@ public enum PushProvider: String, Decodable { // This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, // and .local for periodic background checks -public enum NotificationsMode: String, Decodable, SelectableItem { +public enum NotificationsMode: String, Decodable, SelectableItem, Hashable { case off = "OFF" case periodic = "PERIODIC" case instant = "INSTANT" @@ -1644,7 +1644,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public static var values: [NotificationsMode] = [.instant, .periodic, .off] } -public enum NotificationPreviewMode: String, SelectableItem, Codable { +public enum NotificationPreviewMode: String, SelectableItem, Codable, Hashable { case hidden case contact case message @@ -1662,7 +1662,7 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } -public struct RemoteCtrlInfo: Decodable { +public struct RemoteCtrlInfo: Decodable, Hashable { public var remoteCtrlId: Int64 public var ctrlDeviceName: String public var sessionState: RemoteCtrlSessionState? @@ -1672,7 +1672,7 @@ public struct RemoteCtrlInfo: Decodable { } } -public enum RemoteCtrlSessionState: Decodable { +public enum RemoteCtrlSessionState: Decodable, Hashable { case starting case searching case connecting @@ -1687,17 +1687,17 @@ public enum RemoteCtrlStopReason: Decodable { case disconnected } -public struct CtrlAppInfo: Decodable { +public struct CtrlAppInfo: Decodable, Hashable { public var appVersionRange: AppVersionRange public var deviceName: String } -public struct AppVersionRange: Decodable { +public struct AppVersionRange: Decodable, Hashable { public var minVersion: String public var maxVersion: String } -public struct CoreVersionInfo: Decodable { +public struct CoreVersionInfo: Decodable, Hashable { public var version: String public var simplexmqVersion: String public var simplexmqCommit: String @@ -1719,7 +1719,7 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } -public enum ChatError: Decodable { +public enum ChatError: Decodable, Hashable { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) @@ -1728,7 +1728,7 @@ public enum ChatError: Decodable { case invalidJSON(json: String) } -public enum ChatErrorType: Decodable { +public enum ChatErrorType: Decodable, Hashable { case noActiveUser case noConnectionUser(agentConnId: String) case noSndFileUser(agentSndFileId: String) @@ -1807,7 +1807,7 @@ public enum ChatErrorType: Decodable { case exception(message: String) } -public enum StoreError: Decodable { +public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) case userNotFoundByName(contactName: ContactName) @@ -1867,7 +1867,7 @@ public enum StoreError: Decodable { case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) } -public enum DatabaseError: Decodable { +public enum DatabaseError: Decodable, Hashable { case errorEncrypted case errorPlaintext case errorNoFile(dbFile: String) @@ -1875,12 +1875,12 @@ public enum DatabaseError: Decodable { case errorOpen(sqliteError: SQLiteError) } -public enum SQLiteError: Decodable { +public enum SQLiteError: Decodable, Hashable { case errorNotADatabase case error(String) } -public enum AgentErrorType: Decodable { +public enum AgentErrorType: Decodable, Hashable { case CMD(cmdErr: CommandErrorType) case CONN(connErr: ConnectionErrorType) case SMP(smpErr: ProtocolErrorType) @@ -1894,7 +1894,7 @@ public enum AgentErrorType: Decodable { case INACTIVE } -public enum CommandErrorType: Decodable { +public enum CommandErrorType: Decodable, Hashable { case PROHIBITED case SYNTAX case NO_CONN @@ -1902,7 +1902,7 @@ public enum CommandErrorType: Decodable { case LARGE } -public enum ConnectionErrorType: Decodable { +public enum ConnectionErrorType: Decodable, Hashable { case NOT_FOUND case DUPLICATE case SIMPLEX @@ -1910,7 +1910,7 @@ public enum ConnectionErrorType: Decodable { case NOT_AVAILABLE } -public enum BrokerErrorType: Decodable { +public enum BrokerErrorType: Decodable, Hashable { case RESPONSE(smpErr: String) case UNEXPECTED case NETWORK @@ -1919,7 +1919,7 @@ public enum BrokerErrorType: Decodable { case TIMEOUT } -public enum ProtocolErrorType: Decodable { +public enum ProtocolErrorType: Decodable, Hashable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) @@ -1930,7 +1930,7 @@ public enum ProtocolErrorType: Decodable { case INTERNAL } -public enum XFTPErrorType: Decodable { +public enum XFTPErrorType: Decodable, Hashable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) @@ -1947,7 +1947,7 @@ public enum XFTPErrorType: Decodable { case INTERNAL } -public enum RCErrorType: Decodable { +public enum RCErrorType: Decodable, Hashable { case `internal`(internalErr: String) case identity case noLocalAddress @@ -1965,7 +1965,7 @@ public enum RCErrorType: Decodable { case syntax(syntaxErr: String) } -public enum ProtocolCommandError: Decodable { +public enum ProtocolCommandError: Decodable, Hashable { case UNKNOWN case SYNTAX case PROHIBITED @@ -1974,7 +1974,7 @@ public enum ProtocolCommandError: Decodable { case NO_ENTITY } -public enum ProtocolTransportError: Decodable { +public enum ProtocolTransportError: Decodable, Hashable { case badBlock case largeMsg case badSession @@ -1982,14 +1982,14 @@ public enum ProtocolTransportError: Decodable { case handshake(handshakeErr: SMPHandshakeError) } -public enum SMPHandshakeError: Decodable { +public enum SMPHandshakeError: Decodable, Hashable { case PARSE case VERSION case IDENTITY case BAD_AUTH } -public enum SMPAgentError: Decodable { +public enum SMPAgentError: Decodable, Hashable { case A_MESSAGE case A_PROHIBITED case A_VERSION @@ -1998,12 +1998,12 @@ public enum SMPAgentError: Decodable { case A_QUEUE(queueErr: String) } -public enum ArchiveError: Decodable { +public enum ArchiveError: Decodable, Hashable { case `import`(chatError: ChatError) case importFile(file: String, chatError: ChatError) } -public enum RemoteCtrlError: Decodable { +public enum RemoteCtrlError: Decodable, Hashable { case inactive case badState case busy @@ -2017,14 +2017,14 @@ public enum RemoteCtrlError: Decodable { case protocolError } -public struct MigrationFileLinkData: Codable { +public struct MigrationFileLinkData: Codable, Hashable { let networkConfig: NetworkConfig? public init(networkConfig: NetworkConfig) { self.networkConfig = networkConfig } - public struct NetworkConfig: Codable { + public struct NetworkConfig: Codable, Hashable { let socksProxy: String? let hostMode: HostMode? let requiredHostMode: Bool? @@ -2056,7 +2056,7 @@ public struct MigrationFileLinkData: Codable { } } -public struct AppSettings: Codable, Equatable { +public struct AppSettings: Codable, Equatable, Hashable { public var networkConfig: NetCfg? = nil public var privacyEncryptLocalFiles: Bool? = nil public var privacyAskToApproveRelays: Bool? = nil @@ -2130,7 +2130,7 @@ public struct AppSettings: Codable, Equatable { } } -public enum AppSettingsNotificationMode: String, Codable { +public enum AppSettingsNotificationMode: String, Codable, Hashable { case off case periodic case instant @@ -2158,13 +2158,13 @@ public enum AppSettingsNotificationMode: String, Codable { // case message //} -public enum AppSettingsLockScreenCalls: String, Codable { +public enum AppSettingsLockScreenCalls: String, Codable, Hashable { case disable case show case accept } -public struct UserNetworkInfo: Codable, Equatable { +public struct UserNetworkInfo: Codable, Equatable, Hashable { public let networkType: UserNetworkType public let online: Bool @@ -2174,7 +2174,7 @@ public struct UserNetworkInfo: Codable, Equatable { } } -public enum UserNetworkType: String, Codable { +public enum UserNetworkType: String, Codable, Hashable { case none case cellular case wifi @@ -2192,7 +2192,7 @@ public enum UserNetworkType: String, Codable { } } -public struct RcvMsgInfo: Codable { +public struct RcvMsgInfo: Codable, Hashable { var msgId: Int64 var msgDeliveryId: Int64 var msgDeliveryStatus: String @@ -2200,7 +2200,7 @@ public struct RcvMsgInfo: Codable { var agentMsgMeta: String } -public struct QueueInfo: Codable { +public struct QueueInfo: Codable, Hashable { var qiSnd: Bool var qiNtf: Bool var qiSub: QSub? @@ -2208,25 +2208,25 @@ public struct QueueInfo: Codable { var qiMsg: MsgInfo? } -public struct QSub: Codable { +public struct QSub: Codable, Hashable { var qSubThread: QSubThread var qDelivered: String? } -public enum QSubThread: String, Codable { +public enum QSubThread: String, Codable, Hashable { case noSub case subPending case subThread case prohibitSub } -public struct MsgInfo: Codable { +public struct MsgInfo: Codable, Hashable { var msgId: String var msgTs: Date var msgType: MsgType } -public enum MsgType: String, Codable { +public enum MsgType: String, Codable, Hashable { case message case quota } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1f58ee2363..d2a7e704ad 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI -public struct User: Identifiable, Decodable, UserLike, NamedChat { +public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName @@ -52,7 +52,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat { ) } -public struct UserRef: Identifiable, Decodable, UserLike { +public struct UserRef: Identifiable, Decodable, UserLike, Hashable { public var userId: Int64 public var localDisplayName: ContactName @@ -63,12 +63,12 @@ public protocol UserLike: Identifiable { var userId: Int64 { get } } -public struct UserPwdHash: Decodable { +public struct UserPwdHash: Decodable, Hashable { public var hash: String public var salt: String } -public struct UserInfo: Decodable, Identifiable { +public struct UserInfo: Decodable, Identifiable, Hashable { public var user: User public var unreadCount: Int @@ -89,7 +89,7 @@ public typealias ContactName = String public typealias GroupName = String -public struct Profile: Codable, NamedChat { +public struct Profile: Codable, NamedChat, Hashable { public init( displayName: String, fullName: String, @@ -121,7 +121,7 @@ public struct Profile: Codable, NamedChat { ) } -public struct LocalProfile: Codable, NamedChat { +public struct LocalProfile: Codable, NamedChat, Hashable { public init( profileId: Int64, displayName: String, @@ -171,13 +171,13 @@ public func fromLocalProfile (_ profile: LocalProfile) -> Profile { Profile(displayName: profile.displayName, fullName: profile.fullName, image: profile.image, contactLink: profile.contactLink, preferences: profile.preferences) } -public struct UserProfileUpdateSummary: Decodable { +public struct UserProfileUpdateSummary: Decodable, Hashable { public var updateSuccesses: Int public var updateFailures: Int public var changedContacts: [Contact] } -public enum ChatType: String { +public enum ChatType: String, Hashable { case direct = "@" case group = "#" case local = "*" @@ -202,7 +202,7 @@ extension NamedChat { public typealias ChatId = String -public struct FullPreferences: Decodable, Equatable { +public struct FullPreferences: Decodable, Equatable, Hashable { public var timedMessages: TimedMessagesPreference public var fullDelete: SimplePreference public var reactions: SimplePreference @@ -232,7 +232,7 @@ public struct FullPreferences: Decodable, Equatable { ) } -public struct Preferences: Codable { +public struct Preferences: Codable, Hashable { public var timedMessages: TimedMessagesPreference? public var fullDelete: SimplePreference? public var reactions: SimplePreference? @@ -308,11 +308,11 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac ) } -public protocol Preference: Codable, Equatable { +public protocol Preference: Codable, Equatable, Hashable { var allow: FeatureAllowed { get set } } -public struct SimplePreference: Preference { +public struct SimplePreference: Preference, Hashable { public var allow: FeatureAllowed public init(allow: FeatureAllowed) { @@ -320,7 +320,7 @@ public struct SimplePreference: Preference { } } -public struct TimedMessagesPreference: Preference { +public struct TimedMessagesPreference: Preference, Hashable { public var allow: FeatureAllowed public var ttl: Int? @@ -334,7 +334,7 @@ public struct TimedMessagesPreference: Preference { } } -public enum CustomTimeUnit { +public enum CustomTimeUnit: Hashable { case second case minute case hour @@ -433,7 +433,7 @@ public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey { return CustomTimeUnit.toShortText(seconds: seconds) } -public struct ContactUserPreferences: Decodable { +public struct ContactUserPreferences: Decodable, Hashable { public var timedMessages: ContactUserPreference public var fullDelete: ContactUserPreference public var reactions: ContactUserPreference @@ -483,7 +483,7 @@ public struct ContactUserPreferences: Decodable { ) } -public struct ContactUserPreference: Decodable { +public struct ContactUserPreference: Decodable, Hashable { public var enabled: FeatureEnabled public var userPreference: ContactUserPref

public var contactPreference: P @@ -495,7 +495,7 @@ public struct ContactUserPreference: Decodable { } } -public struct FeatureEnabled: Decodable { +public struct FeatureEnabled: Decodable, Hashable { public var forUser: Bool public var forContact: Bool @@ -526,7 +526,7 @@ public struct FeatureEnabled: Decodable { } } -public enum ContactUserPref: Decodable { +public enum ContactUserPref: Decodable, Hashable { case contact(preference: P) // contact override is set case user(preference: P) // global user default is used @@ -547,7 +547,7 @@ public protocol Feature { var text: String { get } } -public enum ChatFeature: String, Decodable, Feature { +public enum ChatFeature: String, Decodable, Feature, Hashable { case timedMessages case fullDelete case reactions @@ -690,7 +690,7 @@ public enum ChatFeature: String, Decodable, Feature { } } -public enum GroupFeature: String, Decodable, Feature { +public enum GroupFeature: String, Decodable, Feature, Hashable { case timedMessages case directMessages case fullDelete @@ -890,7 +890,7 @@ public enum ContactFeatureAllowed: Identifiable, Hashable { } } -public struct ContactFeaturesAllowed: Equatable { +public struct ContactFeaturesAllowed: Equatable, Hashable { public var timedMessagesAllowed: Bool public var timedMessagesTTL: Int? public var fullDelete: ContactFeatureAllowed @@ -968,7 +968,7 @@ public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureA } } -public enum FeatureAllowed: String, Codable, Identifiable { +public enum FeatureAllowed: String, Codable, Identifiable, Hashable { case always case yes case no @@ -986,7 +986,7 @@ public enum FeatureAllowed: String, Codable, Identifiable { } } -public struct FullGroupPreferences: Decodable, Equatable { +public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var timedMessages: TimedMessagesGroupPreference public var directMessages: RoleGroupPreference public var fullDelete: GroupPreference @@ -1028,7 +1028,7 @@ public struct FullGroupPreferences: Decodable, Equatable { ) } -public struct GroupPreferences: Codable { +public struct GroupPreferences: Codable, Hashable { public var timedMessages: TimedMessagesGroupPreference? public var directMessages: RoleGroupPreference? public var fullDelete: GroupPreference? @@ -1083,7 +1083,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group ) } -public struct GroupPreference: Codable, Equatable { +public struct GroupPreference: Codable, Equatable, Hashable { public var enable: GroupFeatureEnabled public var on: Bool { @@ -1107,7 +1107,7 @@ public struct GroupPreference: Codable, Equatable { } } -public struct RoleGroupPreference: Codable, Equatable { +public struct RoleGroupPreference: Codable, Equatable, Hashable { public var enable: GroupFeatureEnabled public var role: GroupMemberRole? @@ -1121,7 +1121,7 @@ public struct RoleGroupPreference: Codable, Equatable { } } -public struct TimedMessagesGroupPreference: Codable, Equatable { +public struct TimedMessagesGroupPreference: Codable, Equatable, Hashable { public var enable: GroupFeatureEnabled public var ttl: Int? @@ -1135,7 +1135,7 @@ public struct TimedMessagesGroupPreference: Codable, Equatable { } } -public enum GroupFeatureEnabled: String, Codable, Identifiable { +public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { case on case off @@ -1158,7 +1158,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable { } } -public enum ChatInfo: Identifiable, Decodable, NamedChat { +public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) case group(groupInfo: GroupInfo) case local(noteFolder: NoteFolder) @@ -1370,7 +1370,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } - public enum ShowEnableVoiceMessagesAlert { + public enum ShowEnableVoiceMessagesAlert: Hashable { case userEnable case askContact case groupOwnerCan @@ -1443,7 +1443,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } - public struct SampleData { + public struct SampleData: Hashable { public var direct: ChatInfo public var group: ChatInfo public var local: ChatInfo @@ -1460,7 +1460,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { ) } -public struct ChatData: Decodable, Identifiable { +public struct ChatData: Decodable, Identifiable, Hashable { public var chatInfo: ChatInfo public var chatItems: [ChatItem] public var chatStats: ChatStats @@ -1476,7 +1476,7 @@ public struct ChatData: Decodable, Identifiable { } } -public struct ChatStats: Decodable { +public struct ChatStats: Decodable, Hashable { public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { self.unreadCount = unreadCount self.minUnreadItemId = minUnreadItemId @@ -1488,7 +1488,7 @@ public struct ChatStats: Decodable { public var unreadChat: Bool = false } -public struct Contact: Identifiable, Decodable, NamedChat { +public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var contactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile @@ -1574,12 +1574,12 @@ public struct Contact: Identifiable, Decodable, NamedChat { ) } -public enum ContactStatus: String, Decodable { +public enum ContactStatus: String, Decodable, Hashable { case active = "active" case deleted = "deleted" } -public struct ContactRef: Decodable, Equatable { +public struct ContactRef: Decodable, Equatable, Hashable { var contactId: Int64 public var agentConnId: String var connId: Int64 @@ -1588,12 +1588,12 @@ public struct ContactRef: Decodable, Equatable { public var id: ChatId { get { "@\(contactId)" } } } -public struct ContactSubStatus: Decodable { +public struct ContactSubStatus: Decodable, Hashable { public var contact: Contact public var contactError: ChatError? } -public struct Connection: Decodable { +public struct Connection: Decodable, Hashable { public var connId: Int64 public var agentConnId: String public var peerChatVRange: VersionRange @@ -1637,7 +1637,7 @@ public struct Connection: Decodable { ) } -public struct VersionRange: Decodable { +public struct VersionRange: Decodable, Hashable { public init(minVersion: Int, maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion @@ -1651,7 +1651,7 @@ public struct VersionRange: Decodable { } } -public struct SecurityCode: Decodable, Equatable { +public struct SecurityCode: Decodable, Equatable, Hashable { public init(securityCode: String, verifiedAt: Date) { self.securityCode = securityCode self.verifiedAt = verifiedAt @@ -1661,7 +1661,7 @@ public struct SecurityCode: Decodable, Equatable { public var verifiedAt: Date } -public struct UserContact: Decodable { +public struct UserContact: Decodable, Hashable { public var userContactLinkId: Int64 // public var connReqContact: String public var groupId: Int64? @@ -1679,7 +1679,7 @@ public struct UserContact: Decodable { } } -public struct UserContactRequest: Decodable, NamedChat { +public struct UserContactRequest: Decodable, NamedChat, Hashable { var contactRequestId: Int64 public var userContactLinkId: Int64 public var cReqChatVRange: VersionRange @@ -1708,7 +1708,7 @@ public struct UserContactRequest: Decodable, NamedChat { ) } -public struct PendingContactConnection: Decodable, NamedChat { +public struct PendingContactConnection: Decodable, NamedChat, Hashable { public var pccConnId: Int64 var pccAgentConnId: String var pccConnStatus: ConnStatus @@ -1798,7 +1798,7 @@ public struct PendingContactConnection: Decodable, NamedChat { } } -public enum ConnStatus: String, Decodable { +public enum ConnStatus: String, Decodable, Hashable { case new = "new" case joined = "joined" case requested = "requested" @@ -1822,7 +1822,7 @@ public enum ConnStatus: String, Decodable { } } -public struct Group: Decodable { +public struct Group: Decodable, Hashable { public var groupInfo: GroupInfo public var members: [GroupMember] @@ -1832,7 +1832,7 @@ public struct Group: Decodable { } } -public struct GroupInfo: Identifiable, Decodable, NamedChat { +public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupId: Int64 var localDisplayName: GroupName public var groupProfile: GroupProfile @@ -1878,12 +1878,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { ) } -public struct GroupRef: Decodable { +public struct GroupRef: Decodable, Hashable { public var groupId: Int64 var localDisplayName: GroupName } -public struct GroupProfile: Codable, NamedChat { +public struct GroupProfile: Codable, NamedChat, Hashable { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { self.displayName = displayName self.fullName = fullName @@ -1905,7 +1905,7 @@ public struct GroupProfile: Codable, NamedChat { ) } -public struct GroupMember: Identifiable, Decodable { +public struct GroupMember: Identifiable, Decodable, Hashable { public var groupMemberId: Int64 public var groupId: Int64 public var memberId: String @@ -2037,21 +2037,21 @@ public struct GroupMember: Identifiable, Decodable { ) } -public struct GroupMemberSettings: Codable { +public struct GroupMemberSettings: Codable, Hashable { public var showMessages: Bool } -public struct GroupMemberRef: Decodable { +public struct GroupMemberRef: Decodable, Hashable { var groupMemberId: Int64 var profile: Profile } -public struct GroupMemberIds: Decodable { +public struct GroupMemberIds: Decodable, Hashable { var groupMemberId: Int64 var groupId: Int64 } -public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable { +public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { case observer = "observer" case author = "author" case member = "member" @@ -2085,7 +2085,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod } } -public enum GroupMemberCategory: String, Decodable { +public enum GroupMemberCategory: String, Decodable, Hashable { case userMember = "user" case inviteeMember = "invitee" case hostMember = "host" @@ -2093,7 +2093,7 @@ public enum GroupMemberCategory: String, Decodable { case postMember = "post" } -public enum GroupMemberStatus: String, Decodable { +public enum GroupMemberStatus: String, Decodable, Hashable { case memRemoved = "removed" case memLeft = "left" case memGroupDeleted = "deleted" @@ -2142,7 +2142,7 @@ public enum GroupMemberStatus: String, Decodable { } } -public struct NoteFolder: Identifiable, Decodable, NamedChat { +public struct NoteFolder: Identifiable, Decodable, NamedChat, Hashable { public var noteFolderId: Int64 public var favorite: Bool public var unread: Bool @@ -2175,18 +2175,18 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat { ) } -public enum InvitedBy: Decodable { +public enum InvitedBy: Decodable, Hashable { case contact(byContactId: Int64) case user case unknown } -public struct MemberSubError: Decodable { +public struct MemberSubError: Decodable, Hashable { var member: GroupMemberIds var memberError: ChatError } -public enum ConnectionEntity: Decodable { +public enum ConnectionEntity: Decodable, Hashable { case rcvDirectMsgConnection(contact: Contact?) case rcvGroupMsgConnection(groupInfo: GroupInfo, groupMember: GroupMember) case sndFileConnection(sndFileTransfer: SndFileTransfer) @@ -2217,12 +2217,12 @@ public enum ConnectionEntity: Decodable { } } -public struct NtfMsgInfo: Decodable { +public struct NtfMsgInfo: Decodable, Hashable { public var msgId: String public var msgTs: Date } -public struct AChatItem: Decodable { +public struct AChatItem: Decodable, Hashable { public var chatInfo: ChatInfo public var chatItem: ChatItem @@ -2234,19 +2234,19 @@ public struct AChatItem: Decodable { } } -public struct ACIReaction: Decodable { +public struct ACIReaction: Decodable, Hashable { public var chatInfo: ChatInfo public var chatReaction: CIReaction } -public struct CIReaction: Decodable { +public struct CIReaction: Decodable, Hashable { public var chatDir: CIDirection public var chatItem: ChatItem public var sentAt: Date public var reaction: MsgReaction } -public struct ChatItem: Identifiable, Decodable { +public struct ChatItem: Identifiable, Decodable, Hashable { public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta @@ -2596,7 +2596,7 @@ public struct ChatItem: Identifiable, Decodable { } } -public enum CIMergeCategory { +public enum CIMergeCategory: Hashable { case memberConnected case rcvGroupEvent case sndGroupEvent @@ -2605,7 +2605,7 @@ public enum CIMergeCategory { case chatFeature } -public enum CIDirection: Decodable { +public enum CIDirection: Decodable, Hashable { case directSnd case directRcv case groupSnd @@ -2627,7 +2627,7 @@ public enum CIDirection: Decodable { } } -public struct CIMeta: Decodable { +public struct CIMeta: Decodable, Hashable { public var itemId: Int64 public var itemTs: Date var itemText: String @@ -2690,7 +2690,7 @@ public struct CIMeta: Decodable { } } -public struct CITimed: Decodable { +public struct CITimed: Decodable, Hashable { public var ttl: Int public var deleteAt: Date? } @@ -2717,7 +2717,7 @@ private func recent(_ date: Date) -> Bool { return isSameDay || (now < currentDay12 && date >= previousDay18 && date < currentDay00) } -public enum CIStatus: Decodable { +public enum CIStatus: Decodable, Hashable { case sndNew case sndSent(sndProgress: SndCIStatusProgress) case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress) @@ -2787,7 +2787,7 @@ public enum CIStatus: Decodable { } } -public enum SndError: Decodable { +public enum SndError: Decodable, Hashable { case auth case quota case expired @@ -2809,7 +2809,7 @@ public enum SndError: Decodable { } } -public enum SrvError: Decodable, Equatable { +public enum SrvError: Decodable, Hashable { case host case version case other(srvError: String) @@ -2831,17 +2831,17 @@ public enum SrvError: Decodable, Equatable { } } -public enum MsgReceiptStatus: String, Decodable { +public enum MsgReceiptStatus: String, Decodable, Hashable { case ok case badMsgHash } -public enum SndCIStatusProgress: String, Decodable { +public enum SndCIStatusProgress: String, Decodable, Hashable { case partial case complete } -public enum CIDeleted: Decodable { +public enum CIDeleted: Decodable, Hashable { case deleted(deletedTs: Date?) case blocked(deletedTs: Date?) case blockedByAdmin(deletedTs: Date?) @@ -2857,12 +2857,12 @@ public enum CIDeleted: Decodable { } } -public enum MsgDirection: String, Decodable { +public enum MsgDirection: String, Decodable, Hashable { case rcv = "rcv" case snd = "snd" } -public enum CIForwardedFrom: Decodable { +public enum CIForwardedFrom: Decodable, Hashable { case unknown case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) @@ -2882,7 +2882,7 @@ public enum CIForwardedFrom: Decodable { } } -public enum CIDeleteMode: String, Decodable { +public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" } @@ -2891,7 +2891,7 @@ protocol ItemContent { var text: String { get } } -public enum CIContent: Decodable, ItemContent { +public enum CIContent: Decodable, ItemContent, Hashable { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used @@ -3027,7 +3027,7 @@ public enum CIContent: Decodable, ItemContent { } } -public enum MsgDecryptError: String, Decodable { +public enum MsgDecryptError: String, Decodable, Hashable { case ratchetHeader case tooManySkipped case ratchetEarlier @@ -3045,7 +3045,7 @@ public enum MsgDecryptError: String, Decodable { } } -public struct CIQuote: Decodable, ItemContent { +public struct CIQuote: Decodable, ItemContent, Hashable { public var chatDir: CIDirection? public var itemId: Int64? var sharedMsgId: String? = nil @@ -3083,13 +3083,13 @@ public struct CIQuote: Decodable, ItemContent { } } -public struct CIReactionCount: Decodable { +public struct CIReactionCount: Decodable, Hashable { public var reaction: MsgReaction public var userReacted: Bool public var totalReacted: Int } -public enum MsgReaction: Hashable { +public enum MsgReaction: Hashable, Identifiable { case emoji(emoji: MREmojiChar) case unknown(type: String) @@ -3110,9 +3110,16 @@ public enum MsgReaction: Hashable { case type case emoji } + + public var id: String { + switch self { + case let .emoji(emoji): emoji.rawValue + case let .unknown(unknown): unknown + } + } } -public enum MREmojiChar: String, Codable, CaseIterable { +public enum MREmojiChar: String, Codable, CaseIterable, Hashable { case thumbsup = "👍" case thumbsdown = "👎" case smile = "😀" @@ -3153,7 +3160,7 @@ extension MsgReaction: Encodable { } } -public struct CIFile: Decodable { +public struct CIFile: Decodable, Hashable { public var fileId: Int64 public var fileName: String public var fileSize: Int64 @@ -3221,7 +3228,7 @@ public struct CIFile: Decodable { } } -public struct CryptoFile: Codable { +public struct CryptoFile: Codable, Hashable { public var filePath: String // the name of the file, not a full path public var cryptoArgs: CryptoFileArgs? @@ -3268,22 +3275,28 @@ public struct CryptoFile: Codable { static var decryptedUrls = Dictionary() } -public struct CryptoFileArgs: Codable { +public struct CryptoFileArgs: Codable, Hashable { public var fileKey: String public var fileNonce: String } -public struct CancelAction { +public struct CancelAction: Hashable { public var uiAction: String public var alert: AlertInfo } -public struct AlertInfo { +public struct AlertInfo: Hashable { public var title: LocalizedStringKey public var message: LocalizedStringKey public var confirm: LocalizedStringKey } +extension LocalizedStringKey: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine("\(self)") + } +} + private var sndCancelAction = CancelAction( uiAction: NSLocalizedString("Stop file", comment: "cancel file action"), alert: AlertInfo( @@ -3311,13 +3324,13 @@ private var rcvCancelAction = CancelAction( ) ) -public enum FileProtocol: String, Decodable { +public enum FileProtocol: String, Decodable, Hashable { case smp = "smp" case xftp = "xftp" case local = "local" } -public enum CIFileStatus: Decodable, Equatable { +public enum CIFileStatus: Decodable, Equatable, Hashable { case sndStored case sndTransfer(sndProgress: Int64, sndTotal: Int64) case sndComplete @@ -3355,7 +3368,7 @@ public enum CIFileStatus: Decodable, Equatable { } } -public enum FileError: Decodable, Equatable { +public enum FileError: Decodable, Equatable, Hashable { case auth case noFile case relay(srvError: SrvError) @@ -3380,7 +3393,7 @@ public enum FileError: Decodable, Equatable { } } -public enum MsgContent: Equatable { +public enum MsgContent: Equatable, Hashable { case text(String) case link(text: String, preview: LinkPreview) case image(text: String, image: String) @@ -3547,7 +3560,7 @@ extension MsgContent: Encodable { } } -public struct FormattedText: Decodable { +public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? @@ -3556,7 +3569,7 @@ public struct FormattedText: Decodable { } } -public enum Format: Decodable, Equatable { +public enum Format: Decodable, Equatable, Hashable { case bold case italic case strikeThrough @@ -3578,7 +3591,7 @@ public enum Format: Decodable, Equatable { } } -public enum SimplexLinkType: String, Decodable { +public enum SimplexLinkType: String, Decodable, Hashable { case contact case invitation case group @@ -3592,7 +3605,7 @@ public enum SimplexLinkType: String, Decodable { } } -public enum FormatColor: String, Decodable { +public enum FormatColor: String, Decodable, Hashable { case red = "red" case green = "green" case blue = "blue" @@ -3619,7 +3632,7 @@ public enum FormatColor: String, Decodable { } // Struct to use with simplex API -public struct LinkPreview: Codable, Equatable { +public struct LinkPreview: Codable, Equatable, Hashable { public init(uri: URL, title: String, description: String = "", image: String) { self.uri = uri self.title = title @@ -3634,7 +3647,7 @@ public struct LinkPreview: Codable, Equatable { public var image: String } -public enum NtfTknStatus: String, Decodable { +public enum NtfTknStatus: String, Decodable, Hashable { case new = "NEW" case registered = "REGISTERED" case invalid = "INVALID" @@ -3643,22 +3656,22 @@ public enum NtfTknStatus: String, Decodable { case expired = "EXPIRED" } -public struct SndFileTransfer: Decodable { +public struct SndFileTransfer: Decodable, Hashable { } -public struct RcvFileTransfer: Decodable { +public struct RcvFileTransfer: Decodable, Hashable { public let fileId: Int64 } -public struct FileTransferMeta: Decodable { +public struct FileTransferMeta: Decodable, Hashable { public let fileId: Int64 public let fileName: String public let filePath: String public let fileSize: Int64 } -public enum CICallStatus: String, Decodable { +public enum CICallStatus: String, Decodable, Hashable { case pending case missed case rejected @@ -3690,7 +3703,7 @@ public func durationText(_ sec: Int) -> String { : String(format: "%02d:%02d:%02d", m / 60, m % 60, s) } -public enum MsgErrorType: Decodable { +public enum MsgErrorType: Decodable, Hashable { case msgSkipped(fromMsgId: Int64, toMsgId: Int64) case msgBadId(msgId: Int64) case msgBadHash @@ -3707,7 +3720,7 @@ public enum MsgErrorType: Decodable { } } -public struct CIGroupInvitation: Decodable { +public struct CIGroupInvitation: Decodable, Hashable { public var groupId: Int64 public var groupMemberId: Int64 public var localDisplayName: GroupName @@ -3723,18 +3736,18 @@ public struct CIGroupInvitation: Decodable { } } -public enum CIGroupInvitationStatus: String, Decodable { +public enum CIGroupInvitationStatus: String, Decodable, Hashable { case pending case accepted case rejected case expired } -public struct E2EEInfo: Decodable { +public struct E2EEInfo: Decodable, Hashable { public var pqEnabled: Bool } -public enum RcvDirectEvent: Decodable { +public enum RcvDirectEvent: Decodable, Hashable { case contactDeleted case profileUpdated(fromProfile: Profile, toProfile: Profile) @@ -3763,7 +3776,7 @@ public enum RcvDirectEvent: Decodable { } } -public enum RcvGroupEvent: Decodable { +public enum RcvGroupEvent: Decodable, Hashable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected case memberLeft @@ -3819,7 +3832,7 @@ public enum RcvGroupEvent: Decodable { } } -public enum SndGroupEvent: Decodable { +public enum SndGroupEvent: Decodable, Hashable { case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole) case userRole(role: GroupMemberRole) case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool) @@ -3847,7 +3860,7 @@ public enum SndGroupEvent: Decodable { } } -public enum RcvConnEvent: Decodable { +public enum RcvConnEvent: Decodable, Hashable { case switchQueue(phase: SwitchPhase) case ratchetSync(syncStatus: RatchetSyncState) case verificationCodeReset @@ -3884,7 +3897,7 @@ func ratchetSyncStatusToText(_ ratchetSyncStatus: RatchetSyncState) -> String { } } -public enum SndConnEvent: Decodable { +public enum SndConnEvent: Decodable, Hashable { case switchQueue(phase: SwitchPhase, member: GroupMemberRef?) case ratchetSync(syncStatus: RatchetSyncState, member: GroupMemberRef?) case pqEnabled(enabled: Bool) @@ -3921,14 +3934,14 @@ public enum SndConnEvent: Decodable { } } -public enum SwitchPhase: String, Decodable { +public enum SwitchPhase: String, Decodable, Hashable { case started case confirmed case secured case completed } -public enum ChatItemTTL: Hashable, Identifiable, Comparable { +public enum ChatItemTTL: Identifiable, Comparable, Hashable { case day case week case month @@ -3978,13 +3991,13 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable { } } -public struct ChatItemInfo: Decodable { +public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? public var forwardedFromChatItem: AChatItem? } -public struct ChatItemVersion: Decodable { +public struct ChatItemVersion: Decodable, Hashable { public var chatItemVersionId: Int64 public var msgContent: MsgContent public var formattedText: [FormattedText]? @@ -3992,7 +4005,7 @@ public struct ChatItemVersion: Decodable { public var createdAt: Date } -public struct MemberDeliveryStatus: Decodable { +public struct MemberDeliveryStatus: Decodable, Hashable { public var groupMemberId: Int64 public var memberDeliveryStatus: CIStatus public var sentViaProxy: Bool?