From 76bde53206db3c45699e56522e4c35cada6ad021 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 16 Aug 2022 13:13:29 +0100 Subject: [PATCH] ios: scroll buttons and unread counts (#937) * ios: scroll buttons and unread counts * floating buttons for unread counts * remove commented code * remove prints --- apps/ios/Shared/Model/ChatModel.swift | 82 +++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +- apps/ios/Shared/SimpleXApp.swift | 5 - apps/ios/Shared/Views/Chat/ChatView.swift | 164 +++++++++++------- .../Views/ChatList/ChatPreviewView.swift | 6 +- 5 files changed, 179 insertions(+), 84 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 175e9c387e..046d18ce59 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -160,16 +160,6 @@ final class ChatModel: ObservableObject { // add to current chat if chatId == cInfo.id { withAnimation { reversedChatItems.insert(cItem, at: 0) } - if case .rcvNew = cItem.meta.itemStatus { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if self.chatId == cInfo.id { - Task { - await apiMarkChatItemRead(cInfo, cItem) - NtfManager.shared.decNtfBadgeCount() - } - } - } - } } } @@ -230,14 +220,44 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == cInfo.id { - var i = 0 - while i < reversedChatItems.count { - if case .rcvNew = reversedChatItems[i].meta.itemStatus { - reversedChatItems[i].meta.itemStatus = .rcvRead - reversedChatItems[i].viewTimestamp = .now - } - i = i + 1 + markCurrentChatRead() + } + } + + private func markCurrentChatRead(fromIndex i: Int = 0) { + var j = i + while j < reversedChatItems.count { + if case .rcvNew = reversedChatItems[j].meta.itemStatus { + reversedChatItems[j].meta.itemStatus = .rcvRead + reversedChatItems[j].viewTimestamp = .now } + j += 1 + } + } + + func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { + if let cItem = aboveItem { + if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + markCurrentChatRead(fromIndex: i) + if let chat = getChat(cInfo.id) { + var unreadBelow = 0 + var j = i - 1 + while j >= 0 { + if case .rcvNew = reversedChatItems[j].meta.itemStatus { + unreadBelow += 1 + } + j -= 1 + } + // update preview + let markedCount = chat.chatStats.unreadCount - unreadBelow + if markedCount > 0 { + NtfManager.shared.decNtfBadgeCount(by: markedCount) + chat.chatStats.unreadCount -= markedCount + } + } + } + } else { + markChatItemsRead(cInfo) } } @@ -312,6 +332,34 @@ final class ChatModel: ObservableObject { return false } } + + func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts { + var i = 0 + var totalBelow = 0 + var unreadBelow = 0 + while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) { + totalBelow += 1 + if reversedChatItems[i].isRcvNew() { + unreadBelow += 1 + } + i += 1 + } + return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow) + } + + func topItemInView(itemsInView: Set) -> ChatItem? { + let maxIx = reversedChatItems.count - 1 + var i = 0 + let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) } + while i < maxIx && !inView(i) { i += 1 } + while i < maxIx && inView(i) { i += 1 } + return reversedChatItems[min(i - 1, maxIx)] + } +} + +struct UnreadChatItemCounts { + var totalBelow: Int + var unreadBelow: Int } final class Chat: ObservableObject, Identifiable { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0bc3f9a5ee..ac841c5ebf 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -540,13 +540,13 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } -func markChatRead(_ chat: Chat) async { +func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { let minItemId = chat.chatStats.minUnreadItemId - let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId) + let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId) let cInfo = chat.chatInfo try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) - DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) } + DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) } } catch { logger.error("markChatRead apiChatRead error: \(responseError(error))") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 8187de64ec..386ae0c431 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -116,11 +116,6 @@ struct SimpleXApp: App { if let id = chatModel.chatId, let chat = chatModel.getChat(id) { loadChat(chat: chat) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if chatModel.chatId == chat.id { - Task { await markChatRead(chat) } - } - } } if let chatId = chatModel.ntfContactRequest { chatModel.ntfContactRequest = nil diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5522be3c44..324581f3fb 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -26,48 +26,19 @@ struct ChatView: View { @State private var tableView: UITableView? @State private var loadingItems = false @State private var firstPage = false - @State private var scrolledToUnread = false + @State private var itemsInView: Set = [] + @State private var scrollProxy: ScrollViewProxy? var body: some View { let cInfo = chat.chatInfo return VStack { - GeometryReader { g in - let maxWidth = - cInfo.chatType == .group - ? (g.size.width - 28) * 0.84 - 42 - : (g.size.width - 32) * 0.84 - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 5) { - ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in - chatItemView(ci, maxWidth) - .scaleEffect(x: 1, y: -1, anchor: .center) - .onAppear { loadChatItems(cInfo, ci, proxy) } - } - } - } - .onAppear { - DispatchQueue.main.async { - scrollToFirstUnread(proxy) - scrolledToUnread = true - } - markAllRead() - } - .onChange(of: chatModel.reversedChatItems.first?.id) { _ in - scrollToBottom(proxy) - } - .onChange(of: keyboardVisible) { _ in - if keyboardVisible { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - scrollToBottom(proxy, animation: .easeInOut(duration: 1)) - } - } - } - .onTapGesture { hideKeyboard() } + ZStack(alignment: .trailing) { + chatItemsList(cInfo) + if let proxy = scrollProxy { + floatingButtons(proxy) } } - .scaleEffect(x: 1, y: -1, anchor: .center) Spacer(minLength: 0) @@ -154,6 +125,98 @@ struct ChatView: View { .navigationBarBackButtonHidden(true) } + private func chatItemsList(_ cInfo: ChatInfo) -> some View { + GeometryReader { g in + ScrollViewReader { proxy in + ScrollView { + let maxWidth = + cInfo.chatType == .group + ? (g.size.width - 28) * 0.84 - 42 + : (g.size.width - 32) * 0.84 + LazyVStack(spacing: 5) { + ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in + 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.75) { + if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) { + Task { + await apiMarkChatItemRead(cInfo, ci) + NtfManager.shared.decNtfBadgeCount() + } + } + } + } + } + .onDisappear { + itemsInView.remove(ci.viewId) + } + } + } + } + .onAppear { + scrollProxy = proxy + } + .onTapGesture { hideKeyboard() } + } + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + + private func floatingButtons(_ proxy: ScrollViewProxy) -> some View { + let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView) + return VStack { + let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow + if unreadAbove > 0 { + circleButton { + unreadCountText(unreadAbove) + .font(.callout) + .foregroundColor(.accentColor) + } + .onTapGesture { scrollUp(proxy) } + .contextMenu { + Button { + if let ci = chatModel.topItemInView(itemsInView: itemsInView) { + Task { + await markChatRead(chat, aboveItem: ci) + } + } + } label: { + Label("Mark read", systemImage: "checkmark") + } + } + } + Spacer() + if counts.unreadBelow > 0 { + circleButton { + unreadCountText(counts.unreadBelow) + .font(.callout) + .foregroundColor(.accentColor) + } + .onTapGesture { scrollToBottom(proxy) } + } else if counts.totalBelow > 16 { + circleButton { + Image(systemName: "chevron.down") + .foregroundColor(.accentColor) + } + .onTapGesture { scrollToBottom(proxy) } + } + } + .padding() + } + + private func circleButton(_ content: @escaping () -> Content) -> some View { + ZStack { + Circle() + .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground)) + .frame(width: 44, height: 44) + content() + } + } + private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -180,7 +243,7 @@ struct ChatView: View { private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { - if loadingItems || firstPage || !scrolledToUnread { return } + if loadingItems || firstPage { return } loadingItems = true Task { do { @@ -336,34 +399,19 @@ struct ChatView: View { } } - func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { - withAnimation(animation) { scrollToBottom_(proxy) } - } - - func scrollToBottom_(_ proxy: ScrollViewProxy) { - if let id = chatModel.reversedChatItems.first?.id { - proxy.scrollTo(id, anchor: .top) + private func scrollToBottom(_ proxy: ScrollViewProxy) { + if let ci = chatModel.reversedChatItems.first { + withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - // align first unread with the top or the last unread with bottom - func scrollToFirstUnread(_ proxy: ScrollViewProxy) { - if let cItem = chatModel.reversedChatItems.last(where: { $0.isRcvNew() }) { - proxy.scrollTo(cItem.id, anchor: .bottom) - } else { - scrollToBottom_(proxy) + private func scrollUp(_ proxy: ScrollViewProxy) { + if let ci = chatModel.topItemInView(itemsInView: itemsInView) { + withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - func markAllRead() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if chatModel.chatId == chat.id { - Task { await markChatRead(chat) } - } - } - } - - func deleteMessage(_ mode: CIDeleteMode) { + private func deleteMessage(_ mode: CIDeleteMode) { logger.debug("ChatView deleteMessage") Task { logger.debug("ChatView deleteMessage: in Task") diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index efda4c2341..84819a92b6 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -108,7 +108,7 @@ struct ChatPreviewView: View { .padding(.trailing, 36) .padding(.bottom, 4) if unread > 0 { - Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") + unreadCountText(unread) .font(.caption) .foregroundColor(.white) .padding(.horizontal, 4) @@ -170,6 +170,10 @@ struct ChatPreviewView: View { } } +func unreadCountText(_ n: Int) -> Text { + Text(n > 999 ? "\(n / 1000)k" : "\(n)") +} + struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { Group {