Compare commits

...

12 Commits

Author SHA1 Message Date
Levitating Pineapple 6b8c4ccb89 remove separator animation 2024-09-06 18:12:52 +03:00
Levitating Pineapple d3cc775a80 cleanup 2024-09-06 18:07:18 +03:00
Levitating Pineapple bab13fb355 revert view separation 2024-09-06 18:00:23 +03:00
Levitating Pineapple 459058de88 whitespace 2024-09-06 15:46:25 +03:00
Levitating Pineapple a0f2293174 merge master 2024-09-06 15:44:59 +03:00
Evgeny Poberezkin 781fd58369 Merge branch 'master' into lp/floating-buttons-date 2024-09-06 12:41:58 +01:00
Evgeny Poberezkin 6fe7ef4fc0 Merge branch 'master' into lp/floating-buttons-date 2024-09-03 14:40:21 +01:00
Levitating Pineapple 1bb8d5ea96 background date visibility 2024-08-30 22:23:53 +03:00
Levitating Pineapple 694a865b3f send tuple, reduce lookups 2024-08-30 16:12:20 +03:00
Levitating Pineapple e22660dfd9 revert formatTimestampText 2024-08-29 23:41:32 +03:00
Levitating Pineapple 0656836dd0 floating date separator 2024-08-29 23:11:57 +03:00
Levitating Pineapple 537e3c0b6c ios: add floating date separator 2024-08-29 21:43:18 +03:00
2 changed files with 163 additions and 75 deletions
+138 -57
View File
@@ -77,7 +77,12 @@ struct ChatView: View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
chatItemsList() chatItemsList()
// TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates // TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates
floatingButtons(unreadBelow: floatingButtonModel.unreadBelow, isNearBottom: floatingButtonModel.isNearBottom) floatingButtons(
unreadBelow: floatingButtonModel.unreadBelow,
isNearBottom: floatingButtonModel.isNearBottom,
date: floatingButtonModel.date,
isDateVisible: floatingButtonModel.isDateVisible
)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@@ -453,27 +458,60 @@ struct ChatView: View {
static let shared = FloatingButtonModel() static let shared = FloatingButtonModel()
@Published var unreadBelow: Int = 0 @Published var unreadBelow: Int = 0
@Published var isNearBottom: Bool = true @Published var isNearBottom: Bool = true
@Published var date: Date?
@Published var isDateVisible: Bool = false
var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 } var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 }
let visibleItems = PassthroughSubject<[String], Never>() let listState = PassthroughSubject<ListState, Never>()
let scrollOffset = CurrentValueSubject<Double, Never>(0) let scrollOffset = CurrentValueSubject<Double, Never>(0)
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
struct ListState: Equatable {
let topItemDate: Date?
let bottomItemId: ChatItem.ID?
}
private struct ViewUpdate: Equatable {
let unreadBelow: Int
let date: Date?
}
init() { init() {
visibleItems // Date
listState
.receive(on: DispatchQueue.global(qos: .background)) .receive(on: DispatchQueue.global(qos: .background))
.map { itemIds in .map { listState -> ViewUpdate in
if let viewId = itemIds.first, let im = ItemsModel.shared
let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) { return ViewUpdate(
ItemsModel.shared.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in unreadBelow: {
if chatItem.isRcvNew { unread += 1 } if let id = listState.bottomItemId,
} let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
} else { 0 } im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else { 0 }
}(),
date: listState.topItemDate.map { Calendar.current.startOfDay(for: $0) }
)
} }
.removeDuplicates() .removeDuplicates()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.unreadBelow, on: self) .sink {
self.unreadBelow = $0.unreadBelow
self.date = $0.date
}
.store(in: &bag) .store(in: &bag)
// Date visibility
listState
.removeDuplicates()
.map { _ in self.setDate(visibility: true) }
// Hide the date after 1 second of no scrolling
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { self.setDate(visibility: false) }
.store(in: &bag)
// Is near bottom
scrollOffset scrollOffset
.map { $0 < 800 } .map { $0 < 800 }
.removeDuplicates() .removeDuplicates()
@@ -482,49 +520,85 @@ struct ChatView: View {
.assign(to: \.isNearBottom, on: self) .assign(to: \.isNearBottom, on: self)
.store(in: &bag) .store(in: &bag)
} }
}
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View { func resetDate() {
VStack { date = nil
let unreadAbove = chat.chatStats.unreadCount - unreadBelow isDateVisible = false
if unreadAbove > 0 { }
circleButton {
unreadCountText(unreadAbove) private func setDate(visibility isVisible: Bool) {
.font(.callout) Task {
.foregroundColor(theme.colors.primary) if isVisible {
} if !isNearBottom,
.onTapGesture { !isDateVisible,
scrollModel.scrollToNextPage() let date, !Calendar.current.isDateInToday(date) {
} await MainActor.run {
.contextMenu { isDateVisible = true
Button {
Task {
await markChatRead(chat)
} }
} label: { }
Label("Mark read", systemImage: "checkmark") } else if isDateVisible {
await MainActor.run {
isDateVisible = false
} }
} }
} }
Spacer()
if unreadBelow > 0 {
circleButton {
unreadCountText(unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
}
} else if !isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
}
} }
.padding()
}
@ViewBuilder
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool, date: Date?, isDateVisible: Bool) -> some View {
ZStack(alignment: .top) {
if let date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(isDateVisible ? 1 : 0)
}
VStack {
let unreadAbove = chat.chatStats.unreadCount - unreadBelow
if unreadAbove > 0 {
circleButton {
unreadCountText(unreadAbove)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToNextPage()
}
.contextMenu {
Button {
Task {
await markChatRead(chat)
}
} label: {
Label("Mark read", systemImage: "checkmark")
}
}
}
Spacer()
if unreadBelow > 0 {
circleButton {
unreadCountText(unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
}
} else if !isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.onDisappear(perform: floatingButtonModel.resetDate)
} }
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View { private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
@@ -536,6 +610,21 @@ struct ChatView: View {
} }
} }
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 func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button { Button {
CallController.shared.startCall(contact, media) CallController.shared.startCall(contact, media)
@@ -743,15 +832,7 @@ struct ChatView: View {
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 {
Text(String.localizedStringWithFormat( DateSeparator(date: date).padding(8)
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 {
+25 -18
View File
@@ -204,24 +204,31 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
private func updateVisibleItems() { private func updateVisibleItems() {
let fbm = ChatView.FloatingButtonModel.shared if let visibleRows = tableView.indexPathsForVisibleRows,
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset) visibleRows.last?.item ?? 0 < representer.items.count {
fbm.visibleItems.send( let fbm = ChatView.FloatingButtonModel.shared
(tableView.indexPathsForVisibleRows ?? []) fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
.compactMap { indexPath -> String? in fbm.listState.send(.init(
guard let relativeFrame = tableView.superview?.convert( topItemDate: visibleRows
tableView.rectForRow(at: indexPath), .last { isVisible(indexPath: $0) }
from: tableView .map { representer.items[$0.item] }?
) else { return nil } .meta.itemTs,
// Checks that the cell is visible accounting for the added insets bottomItemId: visibleRows
let isVisible = .first { isVisible(indexPath: $0) }
relativeFrame.maxY > InvertedTableView.inset && .map { representer.items[$0.item] }?
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset .id
return indexPath.item < representer.items.count && isVisible ))
? representer.items[indexPath.item].viewId }
: nil }
}
) private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) {
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
} }
} }