Compare commits

...

6 Commits

Author SHA1 Message Date
Levitating Pineapple dea7327610 cleanup 2024-08-28 19:31:55 +03:00
Levitating Pineapple e9dd18a98c ios: add floating date separator 2024-08-28 19:28:56 +03:00
Levitating Pineapple 5406aabdc6 revert id 2024-08-28 16:10:24 +03:00
Levitating Pineapple cc5bc9c2cf account for inset; remove old on appear/disappear blocks 2024-08-28 16:09:51 +03:00
Levitating Pineapple 4153ea7ddc Merge branch 'master' into lp/fix-visible-items 2024-08-28 14:54:13 +03:00
Levitating Pineapple fbe26ed7ff ios: fix inaccurate floating unread counters in chat message view 2024-08-27 23:00:34 +03:00
5 changed files with 112 additions and 65 deletions
@@ -12,7 +12,7 @@ import SimpleXChat
struct FramedItemView: View { struct FramedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem> @EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var preview: UIImage? var preview: UIImage?
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem> @EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem @State var chatItem: ChatItem
@State var image: UIImage? @State var image: UIImage?
@State var player: AVPlayer? = nil @State var player: AVPlayer? = nil
+70 -49
View File
@@ -22,8 +22,8 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat @State @ObservedObject var chat: Chat
@StateObject private var scrollModel = ReverseListScrollModel<ChatItem>() @StateObject private var scrollModel = ReverseListScrollModel()
@StateObject private var floatingButtonModel = FloatingButtonModel() @StateObject private var floatingButtonModel: FloatingButtonModel = .shared
@State private var showChatInfoSheet: Bool = false @State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@@ -74,9 +74,12 @@ struct ChatView: View {
) )
} }
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) { ZStack {
chatItemsList() chatItemsList()
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts) floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
.frame(maxWidth: .infinity, alignment: .trailing)
floatingDate
.frame(maxHeight: .infinity, alignment: .top)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@@ -413,12 +416,6 @@ struct ChatView: View {
revealedChatItem: $revealedChatItem, revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems selectedChatItems: $selectedChatItems
) )
.onAppear {
floatingButtonModel.appeared(viewId: ci.viewId)
}
.onDisappear {
floatingButtonModel.disappeared(viewId: ci.viewId)
}
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: { } loadPage: {
loadChatItems(cInfo) loadChatItems(cInfo)
@@ -429,9 +426,6 @@ struct ChatView: View {
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
Task { await loadChat(chat: chat, search: searchText) } Task { await loadChat(chat: chat, search: searchText) }
} }
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
}
.onChange(of: im.itemAdded) { added in .onChange(of: im.itemAdded) { added in
if added { if added {
im.itemAdded = false im.itemAdded = false
@@ -458,15 +452,13 @@ struct ChatView: View {
} }
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
private enum Event { static let shared = FloatingButtonModel()
case appeared(String)
case disappeared(String)
case chatItemsChanged
}
@Published var unreadChatItemCounts: UnreadChatItemCounts @Published var unreadChatItemCounts: UnreadChatItemCounts
@Published var date: Date?
@Published var isDateVisible: Bool = false
private let events = PassthroughSubject<Event, Never>() let visibleItems = PassthroughSubject<[String], Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
init() { init() {
@@ -475,34 +467,58 @@ struct ChatView: View {
isReallyNearBottom: true, isReallyNearBottom: true,
unreadBelow: 0 unreadBelow: 0
) )
events
// Unread item counts
visibleItems
.receive(on: DispatchQueue.global(qos: .background)) .receive(on: DispatchQueue.global(qos: .background))
.scan(Set<String>()) { itemsInView, event in .map { ChatModel.shared.unreadChatItemCounts(itemsInView: Set($0)) }
var updated = itemsInView
switch event {
case let .appeared(viewId): updated.insert(viewId)
case let .disappeared(viewId): updated.remove(viewId)
case .chatItemsChanged: ()
}
return updated
}
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
.removeDuplicates() .removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true) .throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.unreadChatItemCounts, on: self) .assign(to: \.unreadChatItemCounts, on: self)
.store(in: &bag) .store(in: &bag)
}
func appeared(viewId: String) { // Floating date
events.send(.appeared(viewId)) visibleItems
.receive(on: DispatchQueue.global(qos: .background))
.map { visibleItems -> Date? in
if let viewId = visibleItems.last {
ItemsModel.shared.reversedChatItems
.first { $0.viewId == viewId }
.map { Calendar.current.startOfDay(for: $0.meta.itemTs) }
} else { nil }
}
.removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.date, on: self)
.store(in: &bag)
// Floating date visibility
visibleItems
.removeDuplicates()
.receive(on: DispatchQueue.main)
.map { _ in
if !self.isDateVisible {
withAnimation { self.isDateVisible = true }
}
}
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
if self.isDateVisible {
withAnimation { self.isDateVisible = false }
}
}
.store(in: &bag)
} }
}
func disappeared(viewId: String) { @ViewBuilder
events.send(.disappeared(viewId)) var floatingDate: some View {
} if let date = floatingButtonModel.date {
DateSeparator(date: date)
func chatItemsChanged() { .padding(.vertical, 4).padding(.horizontal, 8)
events.send(.chatItemsChanged) .background(ToolbarMaterial.material(toolbarMaterial))
.clipShape(Capsule())
.opacity(floatingButtonModel.isDateVisible ? 1 : 0)
} }
} }
@@ -692,6 +708,21 @@ struct ChatView: View {
VoiceItemState.chatView = [:] VoiceItemState.chatView = [:]
} }
private struct DateSeparator: View {
let date: Date
var body: some View {
Text(String.localizedStringWithFormat(
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(.dateTime.day().month(.abbreviated))
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
private struct ChatItemWithMenu: View { private struct ChatItemWithMenu: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@@ -763,17 +794,7 @@ struct ChatView: View {
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation) chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date { if let date = timeSeparation.date { DateSeparator(date: date).padding(8) }
Text(String.localizedStringWithFormat(
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(.dateTime.day().month(.abbreviated))
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(8)
}
} }
.overlay { .overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
+38 -12
View File
@@ -8,15 +8,16 @@
import SwiftUI import SwiftUI
import Combine import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top /// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable { struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<Item> let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel<Item>.State @Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item /// Closure, that returns user interface for a given item
let content: (Item) -> Content let content: (ChatItem) -> Content
let loadPage: () -> Void let loadPage: () -> Void
@@ -25,6 +26,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
func updateUIViewController(_ controller: Controller, context: Context) { func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty { if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
switch destination { switch destination {
case .nextPage: case .nextPage:
@@ -42,8 +44,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// Controller, which hosts SwiftUI cells /// Controller, which hosts SwiftUI cells
class Controller: UITableViewController { class Controller: UITableViewController {
private enum Section { case main } private enum Section { case main }
private let representer: ReverseList var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>! private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0 private var itemCount: Int = 0
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
@@ -71,7 +73,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
// 3. Configure data source // 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>( self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in ) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 { if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
@@ -171,8 +173,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
Task { representer.scrollState = .atDestination } Task { representer.scrollState = .atDestination }
} }
func update(items: Array<Item>) { func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(items) snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none dataSource.defaultRowAnimation = .none
@@ -188,6 +190,30 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
) )
} }
itemCount = items.count itemCount = items.count
updateVisibleItems()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateVisibleItems()
}
private func updateVisibleItems() {
ChatView.FloatingButtonModel.shared.visibleItems.send(
(tableView.indexPathsForVisibleRows ?? [])
.compactMap { indexPath -> String? in
let relativeFrame = tableView.superview!.convert(
tableView.rectForRow(at: indexPath),
from: tableView
)
// Checks that the cell is visible accounting for the added insets
let isVisible =
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
return indexPath.item < representer.items.count && isVisible
? representer.items[indexPath.item].viewId
: nil
}
)
} }
} }
@@ -232,12 +258,12 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
/// Manages ``ReverseList`` scrolling /// Manages ``ReverseList`` scrolling
class ReverseListScrollModel<Item: Identifiable>: ObservableObject { class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList`` /// Represents Scroll State of ``ReverseList``
enum State: Equatable { enum State: Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case nextPage case nextPage
case item(Item.ID) case item(ChatItem.ID)
case bottom case bottom
} }
@@ -255,7 +281,7 @@ class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
state = .scrollingTo(.bottom) state = .scrollingTo(.bottom)
} }
func scrollToItem(id: Item.ID) { func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id)) state = .scrollingTo(.item(id))
} }
} }
@@ -324,12 +324,12 @@ struct ChatPreviewView: View {
case let .image(_, image): case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>()) .environmentObject(ReverseListScrollModel())
} }
case let .video(_,image, duration): case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>()) .environmentObject(ReverseListScrollModel())
} }
case let .voice(_, duration): case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) { smallContentPreviewVoice(size: dynamicMediaSize) {