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) {
|
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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user