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 {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat
var chatItem: ChatItem
var preview: UIImage?
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem
@State var image: UIImage?
@State var player: AVPlayer? = nil
+70 -49
View File
@@ -22,8 +22,8 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
@StateObject private var scrollModel = ReverseListScrollModel<ChatItem>()
@StateObject private var floatingButtonModel = FloatingButtonModel()
@StateObject private var scrollModel = ReverseListScrollModel()
@StateObject private var floatingButtonModel: FloatingButtonModel = .shared
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -74,9 +74,12 @@ struct ChatView: View {
)
}
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
ZStack {
chatItemsList()
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
.frame(maxWidth: .infinity, alignment: .trailing)
floatingDate
.frame(maxHeight: .infinity, alignment: .top)
}
connectingText()
if selectedChatItems == nil {
@@ -413,12 +416,6 @@ struct ChatView: View {
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems
)
.onAppear {
floatingButtonModel.appeared(viewId: ci.viewId)
}
.onDisappear {
floatingButtonModel.disappeared(viewId: ci.viewId)
}
.id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: {
loadChatItems(cInfo)
@@ -429,9 +426,6 @@ struct ChatView: View {
.onChange(of: searchText) { _ in
Task { await loadChat(chat: chat, search: searchText) }
}
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
}
.onChange(of: im.itemAdded) { added in
if added {
im.itemAdded = false
@@ -458,15 +452,13 @@ struct ChatView: View {
}
class FloatingButtonModel: ObservableObject {
private enum Event {
case appeared(String)
case disappeared(String)
case chatItemsChanged
}
static let shared = FloatingButtonModel()
@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>()
init() {
@@ -475,34 +467,58 @@ struct ChatView: View {
isReallyNearBottom: true,
unreadBelow: 0
)
events
// Unread item counts
visibleItems
.receive(on: DispatchQueue.global(qos: .background))
.scan(Set<String>()) { itemsInView, event in
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) }
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: Set($0)) }
.removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.unreadChatItemCounts, on: self)
.store(in: &bag)
}
func appeared(viewId: String) {
events.send(.appeared(viewId))
// Floating date
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) {
events.send(.disappeared(viewId))
}
func chatItemsChanged() {
events.send(.chatItemsChanged)
@ViewBuilder
var floatingDate: some View {
if let date = floatingButtonModel.date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(ToolbarMaterial.material(toolbarMaterial))
.clipShape(Capsule())
.opacity(floatingButtonModel.isDateVisible ? 1 : 0)
}
}
@@ -692,6 +708,21 @@ struct ChatView: View {
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 {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@@ -763,17 +794,7 @@ struct ChatView: View {
} else {
VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
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)
}
if let date = timeSeparation.date { DateSeparator(date: date).padding(8) }
}
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
+38 -12
View File
@@ -8,15 +8,16 @@
import SwiftUI
import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
let items: Array<Item>
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel<Item>.State
@Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item
let content: (Item) -> Content
let content: (ChatItem) -> Content
let loadPage: () -> Void
@@ -25,6 +26,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
switch destination {
case .nextPage:
@@ -42,8 +44,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0
private var bag = Set<AnyCancellable>()
@@ -71,7 +73,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
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 }
}
func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
@@ -188,6 +190,30 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
)
}
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
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(Item.ID)
case item(ChatItem.ID)
case bottom
}
@@ -255,7 +281,7 @@ class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: Item.ID) {
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
}
@@ -324,12 +324,12 @@ struct ChatPreviewView: View {
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
.environmentObject(ReverseListScrollModel())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
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):
smallContentPreviewVoice(size: dynamicMediaSize) {