mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dea7327610 | |||
| e9dd18a98c | |||
| 5406aabdc6 | |||
| cc5bc9c2cf | |||
| 4153ea7ddc | |||
| fbe26ed7ff |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
func disappeared(viewId: String) {
|
// Floating date visibility
|
||||||
events.send(.disappeared(viewId))
|
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 chatItemsChanged() {
|
@ViewBuilder
|
||||||
events.send(.chatItemsChanged)
|
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 = [:]
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user