ios: optimise chat switching (#4663)

* ios: shooth chat switching

* debug button

* navigation timeout

* fix scroll crash

* fix merge

* whitespace

* wip

* add spinner; extract load and nav logic

* cleanup

* direct chat button

* cleanup

* showLoadingProgress

* reverse rename

* rename

* spinner layout

* move all programmatic navigation to `openLoadChat`

* remove access restriction

* fix scroll on item added regression

* print

* fix page load regression

* fix member sheet disappearing

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Arturs Krumins
2024-08-13 21:37:48 +03:00
committed by GitHub
parent 32e7fd72d3
commit 7cb3a499b2
14 changed files with 139 additions and 88 deletions

View File

@@ -54,12 +54,50 @@ class ItemsModel: ObservableObject {
willSet { publisher.send() }
}
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
}
}
}
}
}
final class ChatModel: ObservableObject {

View File

@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
}
} else {
chatModel.chatId = content.targetContentIdentifier
if let chatId = content.targetContentIdentifier {
ItemsModel.shared.loadOpenChat(chatId)
}
}
handler()
}

View File

@@ -320,8 +320,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r
}
@@ -332,16 +332,18 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r
}
func loadChat(chat: Chat, search: String = "") {
func loadChat(chat: Chat, search: String = "") async {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
im.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
im.reversedChatItems = chat.chatItems.reversed()
await MainActor.run { im.reversedChatItems = [] }
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
}
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
@@ -701,7 +703,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id }
ItemsModel.shared.loadOpenChat(c.id)
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
@@ -1170,7 +1172,7 @@ func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) a
if contact.sndReady {
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ChatModel.shared.chatId = chat.id
ItemsModel.shared.loadOpenChat(chat.id)
}
}
}
@@ -1736,7 +1738,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run {
if m.chatId == mergedContact.id {
m.chatId = intoContact.id
ItemsModel.shared.loadOpenChat(mergedContact.id)
}
m.removeChat(mergedContact.id)
}

View File

@@ -136,7 +136,7 @@ struct SimpleXApp: App {
chatModel.updateChats(with: chats)
if let id = chatModel.chatId,
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
Task { await loadChat(chat: chat) }
}
if let ncr = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil

View File

@@ -97,7 +97,7 @@ struct ChatItemForwardingView: View {
)
} else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
chatModel.chatId = chat.id
ItemsModel.shared.loadOpenChat(chat.id)
}
}
} label: {

View File

@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
Button {
Task {
await MainActor.run {
chatModel.chatId = forwardedFromItem.chatInfo.id
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
dismiss()
}
}

View File

@@ -59,7 +59,7 @@ struct ChatView: View {
viewBody
}
}
@ViewBuilder
private var viewBody: some View {
let cInfo = chat.chatInfo
@@ -130,16 +130,23 @@ struct ChatView: View {
}
}
}
.appSheet(item: $selectedMember) { member in
Group {
if case let .group(groupInfo) = chat.chatInfo {
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
}
}
.onAppear {
loadChat(chat: chat)
initChatView()
selectedChatItems = nil
initChatView()
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
selectedChatItems = nil
scrollModel.scrollToBottom()
stopAudioPlayer()
if let cId {
selectedChatItems = nil
if let c = chatModel.getChat(cId) {
chat = c
}
@@ -152,8 +159,10 @@ struct ChatView: View {
.onChange(of: revealedChatItem) { _ in
NotificationCenter.postReverseListNeedsLayout()
}
.onChange(of: im.reversedChatItems) { reversedChatItems in
if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 {
.onChange(of: im.isLoading) { isLoading in
if !isLoading,
im.reversedChatItems.count <= loadItemsPerPage,
filtered(im.reversedChatItems).count < 10 {
loadChatItems(chat.chatInfo)
}
}
@@ -221,6 +230,7 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
let isLoading = im.isLoading && im.showLoadingProgress
if selectedChatItems != nil {
Button {
withAnimation {
@@ -243,19 +253,23 @@ struct ChatView: View {
}
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
if !isLoading {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
.disabled(!contact.ready || !contact.active)
}
.disabled(!contact.ready || !contact.active)
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
.tint(isLoading ? Color.clear : nil)
.overlay { if isLoading { ProgressView() } }
}
}
case let .group(groupInfo):
@@ -280,10 +294,14 @@ struct ChatView: View {
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
if !isLoading {
searchButton()
ToggleNtfsButton(chat: chat)
}
} label: {
Image(systemName: "ellipsis")
.tint(isLoading ? Color.clear : nil)
.overlay { if isLoading { ProgressView() } }
}
}
case .local:
@@ -349,9 +367,7 @@ struct ChatView: View {
searchText = ""
searchMode = false
searchFocussed = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
loadChat(chat: chat)
}
Task { await loadChat(chat: chat) }
}
}
.padding(.horizontal)
@@ -408,18 +424,11 @@ struct ChatView: View {
} loadPage: {
loadChatItems(cInfo)
}
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
.padding(.vertical, -InvertedTableView.inset)
.onTapGesture { hideKeyboard() }
.onChange(of: searchText) { _ in
loadChat(chat: chat, search: searchText)
}
.onChange(of: chatModel.chatId) { chatId in
if let chatId, let c = chatModel.getChat(chatId) {
chat = c
showChatInfoSheet = false
loadChat(chat: c)
scrollModel.scrollToBottom()
}
Task { await loadChat(chat: chat, search: searchText) }
}
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
@@ -815,8 +824,8 @@ struct ChatView: View {
HStack(alignment: .top, spacing: 8) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
if let member = m.getGroupMember(member.groupMemberId) {
selectedMember = member
} else {
Task {
await m.loadGroupMembers(groupInfo) {
@@ -825,9 +834,6 @@ struct ChatView: View {
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}

View File

@@ -321,24 +321,24 @@ struct GroupMemberInfoView: View {
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
}
}
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
}
}
@@ -352,8 +352,9 @@ struct GroupMemberInfoView: View {
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {

View File

@@ -152,18 +152,23 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// 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
}
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
Task { representer.scrollState = .atDestination }
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination }
}
func update(items: Array<Item>) {

View File

@@ -114,7 +114,7 @@ struct ChatListNavLink: View {
}
} else {
NavLinkPlain(
tag: chat.chatInfo.id,
chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
@@ -194,7 +194,7 @@ struct ChatListNavLink: View {
}
default:
NavLinkPlain(
tag: chat.chatInfo.id,
chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
@@ -221,7 +221,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain(
tag: chat.chatInfo.id,
chatId: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
@@ -472,9 +472,7 @@ struct ChatListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok {
await MainActor.run {
chatModel.chatId = contact.id
}
ItemsModel.shared.loadOpenChat(contact.id)
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
}

View File

@@ -56,7 +56,7 @@ struct ContactListNavLink: View {
func recentContactNavLink(_ contact: Contact) -> some View {
Button {
dismissAllSheets(animated: true) {
ChatModel.shared.chatId = contact.id
ItemsModel.shared.loadOpenChat(contact.id)
}
} label: {
contactPreview(contact, titleColor: theme.colors.onBackground)
@@ -83,7 +83,7 @@ struct ContactListNavLink: View {
Task {
await MainActor.run {
dismissAllSheets(animated: true) {
ChatModel.shared.chatId = contact.id
ItemsModel.shared.loadOpenChat(contact.id)
}
}
}
@@ -188,9 +188,7 @@ struct ContactListNavLink: View {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok {
await MainActor.run {
ChatModel.shared.chatId = contact.id
}
ItemsModel.shared.loadOpenChat(contact.id)
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact))

View File

@@ -7,16 +7,17 @@
//
import SwiftUI
import SimpleXChat
struct NavLinkPlain<V: Hashable, Label: View>: View {
@State var tag: V
@Binding var selection: V?
struct NavLinkPlain<Label: View>: View {
let chatId: ChatId
@Binding var selection: ChatId?
@ViewBuilder var label: () -> Label
var disabled = false
var body: some View {
ZStack {
Button("") { DispatchQueue.main.async { selection = tag } }
Button("") { ItemsModel.shared.loadOpenChat(chatId) }
.disabled(disabled)
label()
}

View File

@@ -37,7 +37,7 @@ struct AddGroupView: View {
) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) {
m.chatId = groupInfo.id
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
}
}
@@ -52,7 +52,7 @@ struct AddGroupView: View {
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) {
m.chatId = groupInfo.id
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
}
}

View File

@@ -898,11 +898,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = c.id
ItemsModel.shared.loadOpenChat(c.id)
showAlreadyExistsAlert?()
}
} else {
m.chatId = c.id
ItemsModel.shared.loadOpenChat(c.id)
showAlreadyExistsAlert?()
}
}
@@ -917,11 +917,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = g.id
ItemsModel.shared.loadOpenChat(g.id)
showAlreadyExistsAlert?()
}
} else {
m.chatId = g.id
ItemsModel.shared.loadOpenChat(g.id)
showAlreadyExistsAlert?()
}
}