ios: scroll buttons and unread counts (#937)

* ios: scroll buttons and unread counts

* floating buttons for unread counts

* remove commented code

* remove prints
This commit is contained in:
Evgeny Poberezkin
2022-08-16 13:13:29 +01:00
committed by GitHub
parent 0a2f7681d8
commit 76bde53206
5 changed files with 179 additions and 84 deletions

View File

@@ -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<String>) -> 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<String>) -> 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 {

View File

@@ -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))")
}

View File

@@ -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

View File

@@ -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<String> = []
@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: View>(_ 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")

View File

@@ -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 {