mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8c4ccb89 | |||
| d3cc775a80 | |||
| bab13fb355 | |||
| 459058de88 | |||
| a0f2293174 | |||
| 781fd58369 | |||
| 6fe7ef4fc0 | |||
| 1bb8d5ea96 | |||
| 694a865b3f | |||
| e22660dfd9 | |||
| 0656836dd0 | |||
| 537e3c0b6c |
@@ -77,7 +77,12 @@ struct ChatView: View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
chatItemsList()
|
||||
// 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()
|
||||
if selectedChatItems == nil {
|
||||
@@ -453,27 +458,60 @@ struct ChatView: View {
|
||||
static let shared = FloatingButtonModel()
|
||||
@Published var unreadBelow: Int = 0
|
||||
@Published var isNearBottom: Bool = true
|
||||
@Published var date: Date?
|
||||
@Published var isDateVisible: Bool = false
|
||||
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)
|
||||
|
||||
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() {
|
||||
visibleItems
|
||||
// Date
|
||||
listState
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.map { itemIds in
|
||||
if let viewId = itemIds.first,
|
||||
let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) {
|
||||
ItemsModel.shared.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else { 0 }
|
||||
.map { listState -> ViewUpdate in
|
||||
let im = ItemsModel.shared
|
||||
return ViewUpdate(
|
||||
unreadBelow: {
|
||||
if let id = listState.bottomItemId,
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
|
||||
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()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.unreadBelow, on: self)
|
||||
.sink {
|
||||
self.unreadBelow = $0.unreadBelow
|
||||
self.date = $0.date
|
||||
}
|
||||
.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
|
||||
.map { $0 < 800 }
|
||||
.removeDuplicates()
|
||||
@@ -482,49 +520,85 @@ struct ChatView: View {
|
||||
.assign(to: \.isNearBottom, on: self)
|
||||
.store(in: &bag)
|
||||
}
|
||||
}
|
||||
|
||||
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View {
|
||||
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)
|
||||
func resetDate() {
|
||||
date = nil
|
||||
isDateVisible = false
|
||||
}
|
||||
|
||||
private func setDate(visibility isVisible: Bool) {
|
||||
Task {
|
||||
if isVisible {
|
||||
if !isNearBottom,
|
||||
!isDateVisible,
|
||||
let date, !Calendar.current.isDateInToday(date) {
|
||||
await MainActor.run {
|
||||
isDateVisible = true
|
||||
}
|
||||
} 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 {
|
||||
@@ -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 {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, media)
|
||||
@@ -743,15 +832,7 @@ struct ChatView: View {
|
||||
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)
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
|
||||
@@ -204,24 +204,31 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
private func updateVisibleItems() {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
|
||||
fbm.visibleItems.send(
|
||||
(tableView.indexPathsForVisibleRows ?? [])
|
||||
.compactMap { indexPath -> String? in
|
||||
guard let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) else { return nil }
|
||||
// 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
|
||||
}
|
||||
)
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
|
||||
fbm.listState.send(.init(
|
||||
topItemDate: visibleRows
|
||||
.last { isVisible(indexPath: $0) }
|
||||
.map { representer.items[$0.item] }?
|
||||
.meta.itemTs,
|
||||
bottomItemId: visibleRows
|
||||
.first { isVisible(indexPath: $0) }
|
||||
.map { representer.items[$0.item] }?
|
||||
.id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user