mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-25 12:02:13 +00:00
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:
committed by
GitHub
parent
0a2f7681d8
commit
76bde53206
@@ -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 {
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user