mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ee18a610 | |||
| aa65c197f3 | |||
| b078d62921 | |||
| 0a0990c14c | |||
| 07a9050af5 | |||
| 0b3e1614e3 | |||
| bc97b262e5 | |||
| 1b2d7e93f7 | |||
| 4847d2222f | |||
| 73d2929af3 | |||
| 674032b888 | |||
| d47898877a | |||
| 096b9f9613 | |||
| 171abeb145 | |||
| fbddf1d34e | |||
| 9974460dcf | |||
| a8e2fd3c97 | |||
| de1c0f2bf8 | |||
| f167b31e85 | |||
| a2f4615895 | |||
| 339394213b | |||
| c832f9b290 | |||
| b4342c4037 | |||
| 7376b289ca | |||
| f1168a9973 | |||
| 13ece144f2 | |||
| 7168fd9094 | |||
| 1720c843b1 | |||
| 0d0f0aa434 | |||
| 1c697c4a31 | |||
| 9155a2f02a | |||
| 9b6365ca88 | |||
| 6301acd9ff | |||
| 5eaf563b96 | |||
| f8e69ea6e7 | |||
| 5c36d15b2b | |||
| 77df3cc208 | |||
| ff7fcaf7f3 | |||
| 547dbc5271 | |||
| 0e0eeb4a57 | |||
| f8226554ff | |||
| 4cf3da05c3 | |||
| 65c136c7fb | |||
| 7a741e7ac4 | |||
| 165143a111 | |||
| ceb17b23b4 | |||
| 3c8c9d8b52 |
+1
-1
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal build exe:simplex-chat
|
||||
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
|
||||
@@ -58,6 +58,7 @@ class ItemsModel: ObservableObject {
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
@Published var showLoadingProgress = false
|
||||
@State var anchors: [ChatItem.ID] = []
|
||||
|
||||
init() {
|
||||
publisher
|
||||
|
||||
@@ -318,10 +318,12 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
throw r
|
||||
}
|
||||
|
||||
let loadItemsPerPage = 50
|
||||
let loadItemsPerPage = 100
|
||||
let preloadItem = 25
|
||||
let idealChatListSize = 300
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
@@ -329,6 +331,15 @@ func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws ->
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
|
||||
if case let .apiChat(_, chat) = r { return chat.chatItems }
|
||||
if case .chatCmdError(_, _) = r {
|
||||
if case .chatError(_, let chatError) = r {
|
||||
if case .errorStore(let storeError) = chatError {
|
||||
if case .chatItemNotFound(_) = storeError {
|
||||
itemNotFoundAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -339,7 +350,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
if clearItems {
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = []
|
||||
}
|
||||
}
|
||||
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
await MainActor.run {
|
||||
@@ -418,6 +431,13 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]
|
||||
return nil
|
||||
}
|
||||
|
||||
func itemNotFoundAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Message no longer available",
|
||||
message: "The quoted message you are trying to view has been deleted."
|
||||
)
|
||||
}
|
||||
|
||||
private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("send message error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -48,10 +48,18 @@ struct FramedItemView: View {
|
||||
if let qi = chatItem.quotedItem {
|
||||
ciQuoteView(qi)
|
||||
.onTapGesture {
|
||||
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: ci.id)
|
||||
if let itemId = qi.itemId {
|
||||
if !scrollToItem(itemId) {
|
||||
Task {
|
||||
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
|
||||
// await MainActor.run {
|
||||
// let _ = scrollToItem(itemId)
|
||||
//}
|
||||
//}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
itemNotFoundAlert()
|
||||
}
|
||||
}
|
||||
} else if let itemForwarded = chatItem.meta.itemForwarded {
|
||||
@@ -323,6 +331,18 @@ struct FramedItemView: View {
|
||||
return videoWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to an item, if success returns true otherwise false
|
||||
private func scrollToItem(_ itemId: Int64) -> Bool {
|
||||
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == itemId }) {
|
||||
withAnimation {
|
||||
scrollModel.scrollToItem(id: ci.id)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// ChatItemGroups.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Diogo Cunha on 11/11/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
/// Represents an anchor in a list of chat items, indicating where data is missing and should be loaded.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - itemId: The unique identifier of the last item in the loaded list before the anchor.
|
||||
/// This ID corresponds to an item in the chat history, ordered from older to newer items.
|
||||
/// It is typically used when loading items via .around or .initial pagination when loading items
|
||||
/// - indexRange: The range of indexes within `reversedChatItems` array that
|
||||
/// represents the anchor. The first index in this range is the position of the anchor itself.
|
||||
/// For instance, if the array `[0, 1, 2, -100-, 101]` has an anchor at index 3, `indexRange`
|
||||
/// would be `3..<5`, indicating the anchor starts at index 3.
|
||||
/// - indexRangeInParentItems: The range of indexes in the `ReverseList` or parent UI component
|
||||
/// that considers revealed or hidden items, showing where the anchor appears in the visible list.
|
||||
/// The first index in this range points to where the anchor starts in the UI.
|
||||
struct AnchoredRange {
|
||||
let itemId: Int64
|
||||
let indexRange: Range<Int>
|
||||
let indexRangeInParentItems: Range<Int>
|
||||
}
|
||||
struct SectionGroups {
|
||||
let sections: [SectionItems]
|
||||
let anchoredRanges: [AnchoredRange]
|
||||
}
|
||||
|
||||
struct ListItem: Hashable, Equatable {
|
||||
let item: ChatItem
|
||||
let separation: ItemSeparation
|
||||
let prevItemSeparationLargeGap: Bool
|
||||
}
|
||||
|
||||
class SectionItems: ObservableObject {
|
||||
var mergeCategory: CIMergeCategory?
|
||||
var items: [ListItem]
|
||||
@Published var revealed: Bool
|
||||
var showAvatar: Set<Int64>
|
||||
var startIndexInParentItems: Int
|
||||
|
||||
init(mergeCategory: CIMergeCategory?, items: [ListItem], revealed: Bool, showAvatar: Set<Int64>, startIndexInParentItems: Int) {
|
||||
self.mergeCategory = mergeCategory
|
||||
self.items = items
|
||||
self.revealed = revealed
|
||||
self.showAvatar = showAvatar
|
||||
self.startIndexInParentItems = startIndexInParentItems
|
||||
}
|
||||
|
||||
func reveal(_ reveal: Bool, revealedItems: inout Set<Int64>) {
|
||||
if reveal {
|
||||
for item in items {
|
||||
revealedItems.insert(item.item.id)
|
||||
}
|
||||
} else {
|
||||
for item in items {
|
||||
revealedItems.remove(item.item.id)
|
||||
}
|
||||
}
|
||||
self.revealed = reveal
|
||||
}
|
||||
}
|
||||
|
||||
func putIntoGroups(chatItems: [ChatItem], revealedItems: Set<ChatItem.ID>, itemAnchors: Array<ChatItem.ID>) -> SectionGroups {
|
||||
guard !chatItems.isEmpty else { return SectionGroups(sections: [], anchoredRanges: []) }
|
||||
|
||||
var groups: [SectionItems] = []
|
||||
var anchoredRanges: [AnchoredRange] = []
|
||||
var index = 0
|
||||
var unclosedAnchorIndex: Int?
|
||||
var unclosedAnchorIndexInParent: Int?
|
||||
var unclosedAnchorItemId: Int64?
|
||||
var visibleItemIndexInParent = -1
|
||||
var recent: SectionItems?
|
||||
|
||||
while index < chatItems.count {
|
||||
let item = chatItems[index]
|
||||
let next = index + 1 < chatItems.count ? chatItems[index + 1] : nil
|
||||
let category = item.mergeCategory
|
||||
let itemIsAnchor = itemAnchors.contains(item.id)
|
||||
|
||||
let itemSeparation: ItemSeparation
|
||||
let prevItemSeparationLargeGap: Bool
|
||||
|
||||
if let recentSection = recent, index > 0, recentSection.mergeCategory == category, !itemIsAnchor {
|
||||
if recentSection.revealed {
|
||||
let prev = index > 0 ? chatItems[index - 1] : nil
|
||||
itemSeparation = getItemSeparation(item, at: index)
|
||||
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
|
||||
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
|
||||
visibleItemIndexInParent += 1
|
||||
} else {
|
||||
itemSeparation = getItemSeparation(item, at: index)
|
||||
prevItemSeparationLargeGap = false
|
||||
}
|
||||
|
||||
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
|
||||
recentSection.items.append(listItem)
|
||||
if shouldShowAvatar(current: item, older: next) {
|
||||
recentSection.showAvatar.insert(item.id)
|
||||
}
|
||||
} else {
|
||||
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
|
||||
visibleItemIndexInParent += 1
|
||||
|
||||
if revealed {
|
||||
let prev = index > 0 ? chatItems[index - 1] : nil
|
||||
itemSeparation = getItemSeparation(item, at: index)
|
||||
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
|
||||
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
|
||||
} else {
|
||||
itemSeparation = getItemSeparation(item, at: index)
|
||||
prevItemSeparationLargeGap = false
|
||||
}
|
||||
|
||||
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
|
||||
let newSection = SectionItems(
|
||||
mergeCategory: item.mergeCategory,
|
||||
items: [listItem],
|
||||
revealed: revealed,
|
||||
showAvatar: shouldShowAvatar(current: item, older: next) ? [item.id] : [],
|
||||
startIndexInParentItems: visibleItemIndexInParent
|
||||
)
|
||||
groups.append(newSection)
|
||||
recent = newSection
|
||||
}
|
||||
|
||||
if itemIsAnchor {
|
||||
if let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
|
||||
anchoredRanges.append(
|
||||
AnchoredRange(
|
||||
itemId: unclosedId,
|
||||
indexRange: unclosedIndex..<index,
|
||||
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent
|
||||
)
|
||||
)
|
||||
}
|
||||
unclosedAnchorIndex = index
|
||||
unclosedAnchorIndexInParent = visibleItemIndexInParent
|
||||
unclosedAnchorItemId = item.id
|
||||
} else if index + 1 == chatItems.count, let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
|
||||
anchoredRanges.append(
|
||||
AnchoredRange(
|
||||
itemId: unclosedId,
|
||||
indexRange: unclosedIndex..<index + 1,
|
||||
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
return SectionGroups(sections: groups, anchoredRanges: anchoredRanges)
|
||||
}
|
||||
|
||||
func getItemSectionItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> SectionItems? {
|
||||
for sec in sections {
|
||||
if sec.items.firstIndex(where: { $0.item.id == itemId }) != nil {
|
||||
return sec
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIndexInParentItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> Int {
|
||||
for sec in sections {
|
||||
if let index = sec.items.firstIndex(where: { $0.item.id == itemId }) {
|
||||
return sec.startIndexInParentItems + (sec.revealed ? index : 0)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func getNewestItemAtParentIndexOrNull(sections: [SectionItems], parentIndex: Int) -> ChatItem? {
|
||||
for group in sections {
|
||||
let range = group.startIndexInParentItems...(group.startIndexInParentItems + group.items.count - 1)
|
||||
if range.contains(parentIndex) {
|
||||
if group.revealed {
|
||||
return group.items[parentIndex - group.startIndexInParentItems].item
|
||||
} else {
|
||||
return group.items.first?.item
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private func shouldShowAvatar(current: ChatItem, older: ChatItem?) -> Bool {
|
||||
if case let .groupRcv(currentMember) = current.chatDir {
|
||||
if let older = older, case let .groupRcv(olderMember) = older.chatDir {
|
||||
return olderMember.memberId != currentMember.memberId
|
||||
}
|
||||
return true // Show avatar if there is no older item or if older is not a GroupRcv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func getItemSeparationLargeGap(_ chatItem: ChatItem, at index: Int?) -> Bool {
|
||||
let im = ItemsModel.shared
|
||||
|
||||
if let index = index, index > 0, index < im.reversedChatItems.count {
|
||||
let nextItem = im.reversedChatItems[index - 1]
|
||||
let sameMemberAndDirection = nextItem.chatDir.sameDirection(chatItem.chatDir)
|
||||
|
||||
// Return true if they are not the same direction or the time interval is more than 60 seconds.
|
||||
return !sameMemberAndDirection || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
} else {
|
||||
// If there is no next item or it's out of bounds, consider it a large gap.
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,27 @@ import Combine
|
||||
|
||||
private let memberImageSize: CGFloat = 34
|
||||
|
||||
struct ItemSeparation: Equatable, Hashable {
|
||||
let timestamp: Bool;
|
||||
let largeGap: Bool;
|
||||
let date: Date?
|
||||
}
|
||||
|
||||
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
|
||||
let im = ItemsModel.shared
|
||||
if let i, i > 0 && im.reversedChatItems.count >= i {
|
||||
let nextItem = im.reversedChatItems[i - 1]
|
||||
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
return ItemSeparation(
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
|
||||
)
|
||||
} else {
|
||||
return ItemSeparation(timestamp: true, largeGap: true, date: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var im = ItemsModel.shared
|
||||
@@ -32,7 +53,6 @@ struct ChatView: View {
|
||||
@State private var connectionCode: String?
|
||||
@State private var loadingItems = false
|
||||
@State private var firstPage = false
|
||||
@State private var revealedChatItem: ChatItem?
|
||||
@State private var searchMode = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
@@ -46,6 +66,9 @@ struct ChatView: View {
|
||||
@State private var selectedChatItems: Set<Int64>? = nil
|
||||
@State private var showDeleteSelectedMessages: Bool = false
|
||||
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
|
||||
@State private var initialChatItem: ChatItem? = nil
|
||||
@State private var revealedItems: Set<ChatItem.ID> = []
|
||||
@State private var anchors: Array<ChatItem.ID> = []
|
||||
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
@@ -167,6 +190,7 @@ struct ChatView: View {
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
showChatInfoSheet = false
|
||||
firstPage = false
|
||||
selectedChatItems = nil
|
||||
scrollModel.scrollToBottom()
|
||||
stopAudioPlayer()
|
||||
@@ -180,7 +204,7 @@ struct ChatView: View {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onChange(of: revealedChatItem) { _ in
|
||||
.onChange(of: revealedItems.count) { _ in
|
||||
NotificationCenter.postReverseListNeedsLayout()
|
||||
}
|
||||
.onChange(of: im.isLoading) { isLoading in
|
||||
@@ -362,7 +386,18 @@ struct ChatView: View {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
if im.reversedChatItems.count == loadItemsPerPage {
|
||||
loadChatItems(chat.chatInfo, .last(count: loadItemsPerPage))
|
||||
}
|
||||
|
||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
||||
Task {
|
||||
DispatchQueue.main.async {
|
||||
if let firstunreadItem = self.getFirstUnreadItem() {
|
||||
initialChatItem = firstunreadItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
@@ -416,9 +451,10 @@ struct ChatView: View {
|
||||
|
||||
private func chatItemsList() -> some View {
|
||||
let cInfo = chat.chatInfo
|
||||
let mergedItems = filtered(im.reversedChatItems)
|
||||
let groups = putIntoGroups(chatItems: im.reversedChatItems, revealedItems: self.revealedItems, itemAnchors: self.anchors)
|
||||
return GeometryReader { g in
|
||||
ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in
|
||||
ReverseList(groups: groups, scrollState: $scrollModel.state, initialChatItem: $initialChatItem) { li in
|
||||
let ci = li.item
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
? voiceNoFrame
|
||||
@@ -433,13 +469,19 @@ struct ChatView: View {
|
||||
maxWidth: maxWidth,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
revealedChatItem: $revealedChatItem,
|
||||
revealedChatItems: $revealedItems,
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
forwardedChatItems: $forwardedChatItems,
|
||||
onReveal: { revealState in
|
||||
if let sec = getItemSectionItems(sections: groups.sections, itemId: ci.id) {
|
||||
sec.reveal(revealState, revealedItems: &self.revealedItems)
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
||||
} loadPage: {
|
||||
loadChatItems(cInfo)
|
||||
|
||||
} loadPage: { pagination in
|
||||
loadChatItems(cInfo, pagination)
|
||||
}
|
||||
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
||||
.padding(.vertical, -InvertedTableView.inset)
|
||||
@@ -471,6 +513,23 @@ struct ChatView: View {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func getFirstUnreadItem() -> ChatItem? {
|
||||
var maybeItem: ChatItem? = nil
|
||||
for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) {
|
||||
let item = im.reversedChatItems[i]
|
||||
if item.isRcvNew {
|
||||
if item.mergeCategory == nil {
|
||||
return maybeItem ?? item
|
||||
} else {
|
||||
maybeItem = item
|
||||
}
|
||||
} else if maybeItem != nil {
|
||||
return maybeItem
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
static let shared = FloatingButtonModel()
|
||||
@@ -478,17 +537,24 @@ struct ChatView: View {
|
||||
@Published var isNearBottom: Bool = true
|
||||
@Published var date: Date?
|
||||
@Published var isDateVisible: Bool = false
|
||||
@Published var bottomItemIndex: Int = 0
|
||||
var totalUnread: Int = 0
|
||||
var isReallyNearBottom: Bool = true
|
||||
var hideDateWorkItem: DispatchWorkItem?
|
||||
|
||||
func updateOnListChange(_ listState: ListState) {
|
||||
let im = ItemsModel.shared
|
||||
let unreadBelow =
|
||||
let bottomItemIndex =
|
||||
if let id = listState.bottomItemId,
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
|
||||
{
|
||||
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
|
||||
index
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
|
||||
var unreadBelow =
|
||||
if bottomItemIndex != -1 {
|
||||
im.reversedChatItems[..<bottomItemIndex].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else {
|
||||
@@ -508,6 +574,7 @@ struct ChatView: View {
|
||||
it.unreadBelow = unreadBelow
|
||||
it.date = date
|
||||
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
|
||||
it.bottomItemIndex = bottomItemIndex
|
||||
}
|
||||
|
||||
// set floating button indication mode
|
||||
@@ -839,38 +906,133 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo) {
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination = .initial(count: loadItemsPerPage)) {
|
||||
Task {
|
||||
if loadingItems || firstPage { return }
|
||||
if loadingItems { return }
|
||||
loadingItems = true
|
||||
|
||||
do {
|
||||
var reversedPage = Array<ChatItem>()
|
||||
var chatItemsAvailable = true
|
||||
// Load additional items until the page is +50 large after merging
|
||||
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
|
||||
let pagination: ChatPagination =
|
||||
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
|
||||
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
|
||||
} else {
|
||||
.last(count: loadItemsPerPage)
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: searchText
|
||||
)
|
||||
|
||||
if (cInfo.id != chatModel.chatId) {
|
||||
await MainActor.run { loadingItems = false }
|
||||
return
|
||||
}
|
||||
|
||||
let im = ItemsModel.shared
|
||||
var newItems = im.reversedChatItems
|
||||
|
||||
switch pagination {
|
||||
case .last:
|
||||
await MainActor.run {
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
var duplicateFound = false
|
||||
newItems.removeAll {
|
||||
let isDuplicate = newItemIds.contains($0.id)
|
||||
duplicateFound = duplicateFound || isDuplicate
|
||||
return isDuplicate
|
||||
}
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: searchText
|
||||
)
|
||||
chatItemsAvailable = !chatItems.isEmpty
|
||||
reversedPage.append(contentsOf: chatItems.reversed())
|
||||
}
|
||||
await MainActor.run {
|
||||
if reversedPage.count == 0 {
|
||||
firstPage = true
|
||||
} else {
|
||||
im.reversedChatItems.append(contentsOf: reversedPage)
|
||||
|
||||
if !duplicateFound {
|
||||
if let existingItem = im.reversedChatItems.first {
|
||||
anchors = [existingItem.id]
|
||||
}
|
||||
}
|
||||
|
||||
newItems.insert(contentsOf: chatItems.reversed(), at: 0)
|
||||
im.reversedChatItems = newItems
|
||||
loadingItems = false
|
||||
}
|
||||
case .initial:
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chatItems.reversed()
|
||||
anchors = []
|
||||
loadingItems = false
|
||||
}
|
||||
case let .after(chatItemId, _):
|
||||
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let wasSize = newItems.count
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
let indexInAnchors = anchors.firstIndex { $0 == chatItemId }
|
||||
var anchorAfterChatItem: [Int64] = []
|
||||
if let indexInAnchors = indexInAnchors, indexInAnchors + 1 <= anchors.count {
|
||||
anchorAfterChatItem = Array(anchors[indexInAnchors + 1..<anchors.count])
|
||||
}
|
||||
var anchorsToRemove = Set<Int64>()
|
||||
var reachedBottom: Bool = false
|
||||
|
||||
newItems.removeAll { item in
|
||||
let isDuplicate = newItemIds.contains(item.id)
|
||||
if indexInAnchors != nil && newItemIds.contains(item.id) {
|
||||
if anchorAfterChatItem.contains(item.id) {
|
||||
anchorAfterChatItem.removeAll { $0 == item.id }
|
||||
anchorsToRemove.insert(item.id)
|
||||
} else if reachedBottom == false && anchorAfterChatItem.isEmpty {
|
||||
// We passed all anchors and found a duplicated item below all of them, indicating no more anchors below the loaded items.
|
||||
reachedBottom = true
|
||||
}
|
||||
}
|
||||
return isDuplicate
|
||||
}
|
||||
|
||||
let insertAt = indexInCurrentItems - (wasSize - newItems.count)
|
||||
newItems.insert(contentsOf: chatItems.reversed(), at: insertAt)
|
||||
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = newItems
|
||||
var newAnchors = anchors.filter { !anchorsToRemove.contains($0) }
|
||||
|
||||
if reachedBottom {
|
||||
newAnchors = []
|
||||
} else {
|
||||
if let enlargedAnchorIndex = anchors.firstIndex(where: { $0 == chatItemId }) {
|
||||
// Move the anchor to the end of the loaded items.
|
||||
newAnchors[enlargedAnchorIndex] = chatItems.last?.id ?? newAnchors[enlargedAnchorIndex]
|
||||
}
|
||||
}
|
||||
|
||||
anchors = newAnchors
|
||||
loadingItems = false
|
||||
}
|
||||
case let .before(chatItemId, _):
|
||||
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
|
||||
return
|
||||
}
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
newItems.removeAll { newItemIds.contains($0.id) }
|
||||
newItems.insert(contentsOf: chatItems.reversed(), at: min(indexInCurrentItems + 1, newItems.count))
|
||||
|
||||
await MainActor.run {
|
||||
if chatItems.count == 0 || newItems.count == im.reversedChatItems.count {
|
||||
firstPage = true
|
||||
} else {
|
||||
im.reversedChatItems = newItems
|
||||
}
|
||||
anchors = anchors.filter { !newItemIds.contains($0) }
|
||||
loadingItems = false
|
||||
}
|
||||
case .around(_, _):
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
newItems.removeAll { newItemIds.contains($0.id) }
|
||||
newItems.insert(contentsOf: chatItems, at: 0)
|
||||
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = newItems
|
||||
if let lastItemId = chatItems.last?.id {
|
||||
anchors.insert(lastItemId, at: 0)
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
await MainActor.run { loadingItems = false }
|
||||
@@ -892,7 +1054,7 @@ struct ChatView: View {
|
||||
let maxWidth: CGFloat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
@Binding var revealedChatItem: ChatItem?
|
||||
@Binding var revealedChatItems: Set<ChatItem.ID>
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var showDeleteMessage = false
|
||||
@@ -907,25 +1069,8 @@ struct ChatView: View {
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
|
||||
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
|
||||
|
||||
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
|
||||
let im = ItemsModel.shared
|
||||
if let i, i > 0 && im.reversedChatItems.count >= i {
|
||||
let nextItem = im.reversedChatItems[i - 1]
|
||||
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
return (
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
|
||||
)
|
||||
} else {
|
||||
return (timestamp: true, largeGap: true, date: nil)
|
||||
}
|
||||
}
|
||||
let onReveal: (Bool) -> Void
|
||||
var revealed: Bool { revealedChatItems.contains(chatItem.id) }
|
||||
|
||||
var body: some View {
|
||||
let currIndex = m.getChatItemIndex(chatItem)
|
||||
@@ -933,46 +1078,7 @@ struct ChatView: View {
|
||||
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
let range = itemsRange(currIndex, prevHidden)
|
||||
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
|
||||
let im = ItemsModel.shared
|
||||
Group {
|
||||
if revealed, let range = range {
|
||||
let items = Array(zip(Array(range), im.reversedChatItems[range]))
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
|
||||
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(ci.id)
|
||||
selectUnselectChatItem(select: !checked, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(chatItem.id)
|
||||
selectUnselectChatItem(select: !checked, chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
func markAsRead() {
|
||||
if markedRead {
|
||||
return
|
||||
} else {
|
||||
@@ -991,6 +1097,30 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Group {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(chatItem.id)
|
||||
selectUnselectChatItem(select: !checked, chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
markAsRead()
|
||||
}
|
||||
.onChange(of: ChatView.FloatingButtonModel.shared.bottomItemIndex) { _ in
|
||||
markAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
|
||||
@@ -1008,8 +1138,13 @@ struct ChatView: View {
|
||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 600_000000)
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
let currIndex = m.getChatItemIndex(chatItem)
|
||||
if let currIndex = currIndex, currIndex >= ChatView.FloatingButtonModel.shared.bottomItemIndex - 3 {
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
}
|
||||
} else {
|
||||
markedRead = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1573,7 +1708,7 @@ struct ChatView: View {
|
||||
private func hideButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = nil
|
||||
onReveal(false)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
@@ -1648,7 +1783,7 @@ struct ChatView: View {
|
||||
private func revealButton(_ ci: ChatItem) -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = ci
|
||||
onReveal(true)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
@@ -1661,7 +1796,7 @@ struct ChatView: View {
|
||||
private func expandButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = chatItem
|
||||
onReveal(true)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
@@ -1674,7 +1809,7 @@ struct ChatView: View {
|
||||
private func shrinkButton() -> Button<some View> {
|
||||
Button {
|
||||
withConditionalAnimation {
|
||||
revealedChatItem = nil
|
||||
onReveal(false)
|
||||
}
|
||||
} label: {
|
||||
Label (
|
||||
|
||||
@@ -12,14 +12,15 @@ import SimpleXChat
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<ChatItem>
|
||||
let groups: SectionGroups
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
@Binding var initialChatItem: ChatItem?
|
||||
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (ChatItem) -> Content
|
||||
let content: (ListItem) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
let loadPage: (_ pagination: ChatPagination) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(representer: self)
|
||||
@@ -27,18 +28,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
controller.representer = self
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
if case let .scrollingTo(destination) = scrollState, !groups.sections.isEmpty {
|
||||
controller.view.layer.removeAllAnimations()
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
controller.scroll(to: getIndexInParentItems(sections: groups.sections, itemId: id), position: .bottom)
|
||||
case .bottom:
|
||||
controller.scroll(to: 0, position: .top)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
controller.update(groups: groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +47,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ListItem>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
private var revealedItems: Array<ListItem> = []
|
||||
|
||||
init(representer: ReverseList) {
|
||||
self.representer = representer
|
||||
@@ -75,11 +77,16 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ListItem>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
self.representer.loadPage()
|
||||
if self.representer.scrollState == .atDestination, self.representer.initialChatItem == nil {
|
||||
if indexPath.item > self.itemCount - preloadItem,
|
||||
let item = getNewestItemAtParentIndexOrNull(sections: self.representer.groups.sections, parentIndex: self.itemCount - 1) {
|
||||
self.representer.loadPage(.before(chatItemId: item.id, count: loadItemsPerPage))
|
||||
} else if let item = self.getFirstItemAfterPlacholder(indexPath) {
|
||||
self.representer.loadPage(.after(chatItemId: item.id, count: loadItemsPerPage))
|
||||
}
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -149,6 +156,29 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
tableView.clipsToBounds = false
|
||||
parent?.viewIfLoaded?.clipsToBounds = false
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
if let cItem = self.representer.initialChatItem {
|
||||
let index = getIndexInParentItems(sections: self.representer.groups.sections, itemId: cItem.id)
|
||||
|
||||
if index == -1 {
|
||||
return
|
||||
}
|
||||
let indexPath = IndexPath(row: index, section: 0)
|
||||
if !isVisible(indexPath: indexPath) {
|
||||
if tableView.numberOfRows(inSection: indexPath.section) > indexPath.row {
|
||||
let cellRect = tableView.rectForRow(at: indexPath)
|
||||
tableView.setContentOffset(CGPoint(x: 0, y: cellRect.maxY - tableView.bounds.height), animated: false)
|
||||
}
|
||||
}
|
||||
Task {
|
||||
DispatchQueue.main.async {
|
||||
self.representer.initialChatItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
@@ -169,6 +199,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
@@ -181,18 +212,49 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
Task {
|
||||
self.representer.scrollState = .atDestination
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
func update(groups: SectionGroups) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ListItem>()
|
||||
revealedItems.removeAll()
|
||||
groups.sections.forEach { sc in
|
||||
if sc.revealed {
|
||||
revealedItems.append(contentsOf: sc.items)
|
||||
} else if let item = sc.items.first {
|
||||
revealedItems.append(item)
|
||||
}
|
||||
}
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
snapshot.appendItems(revealedItems, toSection: .main)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
)
|
||||
|
||||
let countDiff = max(0, revealedItems.count - itemCount)
|
||||
if tableView.contentOffset.y == 100, itemCount < revealedItems.count, itemCount > 0 {
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: false
|
||||
)
|
||||
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: countDiff, section: 0),
|
||||
at: .top,
|
||||
animated: false
|
||||
)
|
||||
} else {
|
||||
tableView.beginUpdates()
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: false
|
||||
)
|
||||
tableView.endUpdates()
|
||||
}
|
||||
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
tableView.setContentOffset(
|
||||
@@ -200,7 +262,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
itemCount = revealedItems.count
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
@@ -210,17 +272,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
func getListState() -> ListState? {
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||
visibleRows.last?.item ?? 0 < revealedItems.count {
|
||||
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||
|
||||
let topItemDate: Date? =
|
||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[lastVisible.item].meta.itemTs
|
||||
revealedItems[lastVisible.item].item.meta.itemTs
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
revealedItems[firstVisible.item].item.id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
@@ -238,6 +301,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
}
|
||||
|
||||
private func getFirstItemAfterPlacholder(_ indexPath: IndexPath) -> ChatItem? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
|
||||
@@ -149,9 +149,9 @@
|
||||
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */; };
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */; };
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */; };
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */; };
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
|
||||
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
||||
@@ -200,6 +200,7 @@
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */; };
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
|
||||
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
|
||||
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
|
||||
@@ -492,9 +493,9 @@
|
||||
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; sourceTree = "<group>"; };
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; sourceTree = "<group>"; };
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
|
||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
||||
@@ -544,6 +545,7 @@
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemGroups.swift; sourceTree = "<group>"; };
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
|
||||
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
|
||||
@@ -663,8 +665,8 @@
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */,
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */,
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */,
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -734,6 +736,7 @@
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
|
||||
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
@@ -815,8 +818,8 @@
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */,
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */,
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */,
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */,
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */,
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */,
|
||||
5CA059C2279559F40002BEB4 /* Shared */,
|
||||
5CDCAD462818589900503DA2 /* SimpleX NSE */,
|
||||
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
|
||||
@@ -1483,6 +1486,7 @@
|
||||
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
|
||||
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
|
||||
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */,
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
|
||||
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
|
||||
|
||||
@@ -1133,12 +1133,16 @@ public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
|
||||
case around(chatItemId: Int64, count: Int)
|
||||
case initial(count: Int)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .last(count): return "count=\(count)"
|
||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
|
||||
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
|
||||
case let .initial(count): return "initial=\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2663,7 +2663,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
item.isLiveDummy = true
|
||||
return item
|
||||
}
|
||||
|
||||
|
||||
public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: chatDir ?? .directSnd,
|
||||
|
||||
-11
@@ -11,7 +11,6 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ChatSectionArea
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrationToDeviceState
|
||||
@@ -66,8 +65,6 @@ object ChatModel {
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
|
||||
// chatItemId, SectionArea
|
||||
val chatItemsSectionArea = mutableMapOf<Long, ChatSectionArea>()
|
||||
// rhId, chatId
|
||||
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
|
||||
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
|
||||
@@ -332,7 +329,6 @@ object ChatModel {
|
||||
if (chatId.value == cInfo.id) {
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.value.none { it.id == cItem.id }) {
|
||||
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
|
||||
} else {
|
||||
@@ -381,7 +377,6 @@ object ChatModel {
|
||||
} else {
|
||||
cItem
|
||||
}
|
||||
chatItemsSectionArea[ci.id] = ChatSectionArea.Bottom
|
||||
chatItems.add(ci)
|
||||
true
|
||||
}
|
||||
@@ -613,7 +608,6 @@ object ChatModel {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatItems.add(cItem)
|
||||
chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
@@ -621,7 +615,6 @@ object ChatModel {
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.removeLast()
|
||||
chatItemsSectionArea.remove(ChatItem.TEMP_LIVE_CHAT_ITEM_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,10 +2307,6 @@ fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
|
||||
return res
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.removeRange(fromIndex: Int, toIndex: Int) {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); removeRange(fromIndex, toIndex) }
|
||||
}
|
||||
|
||||
fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
|
||||
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
|
||||
}
|
||||
|
||||
+7
-24
@@ -865,15 +865,11 @@ object ChatController {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Pair<Chat, ChatLandingSection>? {
|
||||
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search))
|
||||
if (r is CR.ApiChat) return if (rh == null) Pair(r.chat, r.section) else Pair(r.chat.copy(remoteHostId = rh), r.section)
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_get_chat_item_not_found_title), generalGetString(MR.strings.failed_to_get_chat_item_not_found_description))
|
||||
} else {
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
|
||||
}
|
||||
if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh)
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3472,15 +3468,11 @@ sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Before(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Around(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Initial(val count: Int): ChatPagination()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Last -> "count=${this.count}"
|
||||
is After -> "after=${this.chatItemId} count=${this.count}"
|
||||
is Before -> "before=${this.chatItemId} count=${this.count}"
|
||||
is Around -> "around=${this.chatItemId} count=${this.count}"
|
||||
is Initial -> "initial=${this.count}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -4865,10 +4857,7 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul
|
||||
} else if (type == "apiChat") {
|
||||
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
|
||||
val chat = parseChatData(resp["chat"]!!)
|
||||
val section = resp["section"]?.let {
|
||||
json.decodeFromJsonElement<ChatLandingSection>(it)
|
||||
} ?: ChatLandingSection.Latest
|
||||
return APIResponse(CR.ApiChat(user, chat, section), remoteHostId, corr)
|
||||
return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr)
|
||||
} else if (type == "chatCmdError") {
|
||||
val userObject = resp["user_"]?.jsonObject
|
||||
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
|
||||
@@ -4925,7 +4914,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
|
||||
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val section: ChatLandingSection): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat): CR()
|
||||
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
|
||||
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
|
||||
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
|
||||
@@ -5275,7 +5264,7 @@ sealed class CR {
|
||||
is ChatRunning -> noDetails()
|
||||
is ChatStopped -> noDetails()
|
||||
is ApiChats -> withUser(user, json.encodeToString(chats))
|
||||
is ApiChat -> withUser(user, "section: ${json.encodeToString(section)}\n${json.encodeToString(chat)}")
|
||||
is ApiChat -> withUser(user, json.encodeToString(chat))
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
|
||||
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
|
||||
@@ -5515,12 +5504,6 @@ sealed class GroupLinkPlan {
|
||||
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ChatLandingSection {
|
||||
@SerialName("latest") Latest,
|
||||
@SerialName("unread") Unread,
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
abstract val id: Long
|
||||
abstract val remoteHostId: Long?
|
||||
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatController
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.max
|
||||
|
||||
const val MAX_SECTION_SIZE = 500
|
||||
|
||||
enum class ChatSectionArea {
|
||||
Bottom,
|
||||
Current,
|
||||
Destination
|
||||
}
|
||||
|
||||
data class ChatSectionAreaBoundary (
|
||||
var minIndex: Int,
|
||||
var maxIndex: Int,
|
||||
val area: ChatSectionArea
|
||||
)
|
||||
|
||||
data class ChatSection (
|
||||
val items: MutableList<SectionItems>,
|
||||
val boundary: ChatSectionAreaBoundary,
|
||||
// chatItemId, index in rendered LazyColumn
|
||||
val itemPositions: MutableMap<Long, Int>
|
||||
)
|
||||
|
||||
data class SectionItems (
|
||||
val mergeCategory: CIMergeCategory?,
|
||||
val items: MutableList<ChatItem>,
|
||||
val revealed: Boolean,
|
||||
val showAvatar: MutableSet<Long>,
|
||||
var originalItemsRange: IntRange
|
||||
)
|
||||
|
||||
data class ChatSectionLoader (
|
||||
val position: Int,
|
||||
val sectionArea: ChatSectionArea
|
||||
) {
|
||||
fun prepareItems(items: List<ChatItem>): List<ChatItem> {
|
||||
val chatItemsSectionArea = chatModel.chatItemsSectionArea
|
||||
val itemsToAdd = mutableListOf<ChatItem>()
|
||||
val sectionsToMerge = mutableMapOf<ChatSectionArea, ChatSectionArea>()
|
||||
val itemsThatCouldRequireMerge = mutableListOf<ChatItem>()
|
||||
for (cItem in items) {
|
||||
val itemSectionArea = chatItemsSectionArea[cItem.id]
|
||||
if (itemSectionArea == null) {
|
||||
itemsToAdd.add(cItem)
|
||||
val targetSection = sectionsToMerge[sectionArea] ?: this.sectionArea
|
||||
chatItemsSectionArea[cItem.id] = targetSection
|
||||
|
||||
if (targetSection == this.sectionArea) {
|
||||
itemsThatCouldRequireMerge.add(cItem)
|
||||
}
|
||||
} else if (itemSectionArea != this.sectionArea) {
|
||||
val (targetSection, sectionToDrop) = when (itemSectionArea) {
|
||||
ChatSectionArea.Bottom -> ChatSectionArea.Bottom to this.sectionArea
|
||||
ChatSectionArea.Current -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom to itemSectionArea else itemSectionArea to this.sectionArea
|
||||
ChatSectionArea.Destination -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom to itemSectionArea else itemSectionArea to this.sectionArea
|
||||
}
|
||||
|
||||
if (targetSection != sectionToDrop) {
|
||||
sectionsToMerge[sectionToDrop] = targetSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionsToMerge.isNotEmpty()) {
|
||||
chatModel.chatItems.value.forEach {
|
||||
val currentSection = chatItemsSectionArea[it.id]
|
||||
val newSection = sectionsToMerge[currentSection]
|
||||
if (newSection != null) {
|
||||
chatItemsSectionArea[it.id] = newSection
|
||||
}
|
||||
}
|
||||
|
||||
itemsThatCouldRequireMerge.forEach {
|
||||
val targetSection = sectionsToMerge[sectionArea] ?: sectionArea
|
||||
chatItemsSectionArea[it.id] = targetSection
|
||||
}
|
||||
}
|
||||
|
||||
return itemsToAdd
|
||||
}
|
||||
}
|
||||
|
||||
fun ChatSection.getPreviousShownItem(sectionIndex: Int, itemIndex: Int): ChatItem? {
|
||||
val section = items.getOrNull(sectionIndex) ?: return null
|
||||
|
||||
return if (section.mergeCategory == null) {
|
||||
section.items.getOrNull(itemIndex + 1) ?: items.getOrNull(sectionIndex + 1)?.items?.firstOrNull()
|
||||
} else {
|
||||
items.getOrNull(sectionIndex + 1)?.items?.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun ChatSection.getNextShownItem(sectionIndex: Int, itemIndex: Int): ChatItem? {
|
||||
val section = items.getOrNull(sectionIndex) ?: return null
|
||||
|
||||
return if (section.mergeCategory == null) {
|
||||
section.items.getOrNull(itemIndex - 1) ?: items.getOrNull(sectionIndex - 1)?.items?.lastOrNull()
|
||||
} else {
|
||||
items.getOrNull(sectionIndex - 1)?.items?.lastOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ChatItem>.putIntoSections(revealedItems: Set<Long>): List<ChatSection> {
|
||||
if (isEmpty()) return emptyList()
|
||||
|
||||
val chatItemsSectionArea = chatModel.chatItemsSectionArea
|
||||
val sections = mutableListOf<ChatSection>()
|
||||
val first = this[0]
|
||||
|
||||
val showAvatar = mutableSetOf<Long>()
|
||||
if (first.chatDir is CIDirection.GroupRcv) {
|
||||
val second = getOrNull(1)
|
||||
if (second != null) {
|
||||
if (second.chatDir !is CIDirection.GroupRcv || second.chatDir.groupMember.memberId != first.chatDir.groupMember.memberId) {
|
||||
showAvatar.add(first.id)
|
||||
}
|
||||
} else {
|
||||
showAvatar.add(first.id)
|
||||
}
|
||||
}
|
||||
|
||||
var recent = SectionItems(
|
||||
mergeCategory = first.mergeCategory,
|
||||
items = mutableListOf(first),
|
||||
revealed = first.mergeCategory == null || revealedItems.contains(first.id),
|
||||
showAvatar = showAvatar,
|
||||
originalItemsRange = 0..0
|
||||
)
|
||||
|
||||
val area = chatItemsSectionArea[recent.items[0].id] ?: ChatSectionArea.Bottom
|
||||
|
||||
sections.add(
|
||||
ChatSection(
|
||||
items = mutableListOf(recent),
|
||||
boundary = ChatSectionAreaBoundary(minIndex = 0, maxIndex = 0, area = area),
|
||||
itemPositions = mutableMapOf(recent.items[0].id to 0)
|
||||
)
|
||||
)
|
||||
|
||||
var prev = this[0]
|
||||
var index = 0
|
||||
var positionInList = 0;
|
||||
while (index < size) {
|
||||
if (index == 0) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
val item = this[index]
|
||||
val itemArea = chatItemsSectionArea[item.id] ?: ChatSectionArea.Bottom
|
||||
val existingSection = sections.find { it.boundary.area == itemArea }
|
||||
|
||||
if (existingSection == null) {
|
||||
positionInList++
|
||||
val newSection = SectionItems(
|
||||
mergeCategory = item.mergeCategory,
|
||||
items = mutableListOf(item),
|
||||
revealed = item.mergeCategory == null || revealedItems.contains(item.id),
|
||||
showAvatar = mutableSetOf(item.id),
|
||||
originalItemsRange = index..index
|
||||
)
|
||||
sections.add(
|
||||
ChatSection(
|
||||
items = mutableListOf(newSection),
|
||||
boundary = ChatSectionAreaBoundary(minIndex = index, maxIndex = index, area = itemArea),
|
||||
itemPositions = mutableMapOf(item.id to positionInList)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
recent = existingSection.items.last()
|
||||
val category = item.mergeCategory
|
||||
if (recent.mergeCategory == category) {
|
||||
if (category == null || recent.revealed || revealedItems.contains(item.id)) {
|
||||
positionInList++
|
||||
}
|
||||
if (item.chatDir is CIDirection.GroupRcv && prev.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.memberId != (prev.chatDir as CIDirection.GroupRcv).groupMember.memberId) {
|
||||
recent.showAvatar.add(item.id)
|
||||
}
|
||||
recent.items.add(item)
|
||||
recent.originalItemsRange = recent.originalItemsRange.first..index
|
||||
existingSection.itemPositions[item.id] = positionInList
|
||||
} else {
|
||||
positionInList++
|
||||
val newSectionItems = SectionItems(
|
||||
mergeCategory = item.mergeCategory,
|
||||
items = mutableListOf(item),
|
||||
revealed = item.mergeCategory == null || revealedItems.contains(item.id),
|
||||
showAvatar = if (item.chatDir is CIDirection.GroupRcv && (prev.chatDir !is CIDirection.GroupRcv || (prev.chatDir as CIDirection.GroupRcv).groupMember.memberId != item.chatDir.groupMember.memberId)) {
|
||||
mutableSetOf(item.id)
|
||||
} else {
|
||||
mutableSetOf()
|
||||
},
|
||||
originalItemsRange = index..index
|
||||
)
|
||||
existingSection.itemPositions[item.id] = positionInList
|
||||
existingSection.items.add(newSectionItems)
|
||||
}
|
||||
existingSection.boundary.maxIndex = index
|
||||
}
|
||||
prev = item
|
||||
index++
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
fun List<ChatSection>.chatItemPosition(chatItemId: Long): Int? {
|
||||
for (section in this) {
|
||||
val position = section.itemPositions[chatItemId]
|
||||
if (position != null) {
|
||||
return position
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun List<ChatSection>.revealedItemCount(): Int {
|
||||
var count = 0
|
||||
for (section in this) {
|
||||
var i = 0;
|
||||
while (i < section.items.size) {
|
||||
val item = section.items[i]
|
||||
if (item.revealed) {
|
||||
count += item.items.size
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
fun List<ChatSection>.dropTemporarySections() {
|
||||
val bottomSection = this.find { it.boundary.area == ChatSectionArea.Bottom }
|
||||
if (bottomSection != null) {
|
||||
val itemsOutsideOfSection = chatModel.chatItems.value.lastIndex - bottomSection.boundary.maxIndex
|
||||
chatModel.chatItems.removeRange(fromIndex = 0, toIndex = itemsOutsideOfSection + bottomSection.excessItemCount())
|
||||
chatModel.chatItemsSectionArea.clear()
|
||||
chatModel.chatItems.value.associateTo(chatModel.chatItemsSectionArea) { it.id to ChatSectionArea.Bottom }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatSection.excessItemCount(): Int {
|
||||
return max(boundary.maxIndex - boundary.minIndex + 1 - MAX_SECTION_SIZE, 0)
|
||||
}
|
||||
|
||||
fun landingSectionToArea(chatLandingSection: ChatLandingSection) = when (chatLandingSection) {
|
||||
ChatLandingSection.Latest -> ChatSectionArea.Bottom
|
||||
ChatLandingSection.Unread -> ChatSectionArea.Current
|
||||
}
|
||||
|
||||
suspend fun apiLoadBottomSection(chatInfo: ChatInfo, rhId: Long?) {
|
||||
val chat = chatController.apiGetChat(rh = rhId, type = chatInfo.chatType, id = chatInfo.apiId)
|
||||
if (chatModel.chatId.value != chatInfo.id || chat == null) return
|
||||
withContext(Dispatchers.Main) {
|
||||
val updatedItems = chatModel.chatItems.value.toMutableStateList()
|
||||
var insertIndex = updatedItems.size
|
||||
var needsMerge = false
|
||||
|
||||
for (cItem in chat.first.chatItems.asReversed()) {
|
||||
if (chatModel.chatItemsSectionArea[cItem.id] == null) {
|
||||
updatedItems.add(insertIndex, cItem)
|
||||
chatModel.chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
} else {
|
||||
needsMerge = true
|
||||
chatModel.chatItemsSectionArea[cItem.id] = ChatSectionArea.Bottom
|
||||
insertIndex = max(0, insertIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMerge) {
|
||||
updatedItems.associateTo(chatModel.chatItemsSectionArea) { it.id to ChatSectionArea.Bottom }
|
||||
}
|
||||
|
||||
chatModel.chatItems.replaceAll(updatedItems)
|
||||
}
|
||||
}
|
||||
+235
-424
@@ -47,7 +47,8 @@ import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import kotlin.math.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?)
|
||||
|
||||
@@ -291,35 +292,13 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
}
|
||||
}
|
||||
},
|
||||
loadMessages = { chatId, scrollDirection, (itemId, idx, area) ->
|
||||
val c = chatModel.getChat(chatId) ?: return@ChatLayout
|
||||
loadPrevMessages = { chatId ->
|
||||
val c = chatModel.getChat(chatId)
|
||||
if (chatModel.chatId.value != chatId) return@ChatLayout
|
||||
withBGApi {
|
||||
when (scrollDirection) {
|
||||
ScrollDirection.Up -> {
|
||||
val chatSectionLoader = ChatSectionLoader(idx, area)
|
||||
apiLoadMessages(
|
||||
rhId = c.remoteHostId,
|
||||
chatInfo = c.chatInfo,
|
||||
chatModel = chatModel,
|
||||
itemId = itemId,
|
||||
search = "",
|
||||
chatSectionLoader = chatSectionLoader,
|
||||
)
|
||||
}
|
||||
ScrollDirection.Down -> {
|
||||
val chatSectionLoader = ChatSectionLoader(idx + 1, area)
|
||||
apiLoadMessages(
|
||||
rhId = c.remoteHostId,
|
||||
chatInfo = c.chatInfo,
|
||||
chatModel = chatModel,
|
||||
itemId = itemId,
|
||||
search = "",
|
||||
chatSectionLoader = chatSectionLoader,
|
||||
pagination = ChatPagination.After(itemId, ChatPagination.PRELOAD_COUNT)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
val firstId = chatModel.chatItems.value.firstOrNull()?.id
|
||||
if (c != null && firstId != null) {
|
||||
withBGApi {
|
||||
apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -604,7 +583,7 @@ fun ChatLayout(
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
|
||||
loadPrevMessages: (ChatId) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -632,7 +611,7 @@ fun ChatLayout(
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showSearch: MutableState<Boolean>,
|
||||
showSearch: MutableState<Boolean>
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
|
||||
@@ -670,10 +649,10 @@ fun ChatLayout(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
ChatItemsList(
|
||||
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
|
||||
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages,
|
||||
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy
|
||||
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -943,7 +922,7 @@ fun BoxScope.ChatItemsList(
|
||||
linkMode: SimplexLinkMode,
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadMessages: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
|
||||
loadPrevMessages: (ChatId) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -964,12 +943,11 @@ fun BoxScope.ChatItemsList(
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showViaProxy: Boolean
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollAdjustmentEnabled = remember { mutableStateOf(true) }
|
||||
ScrollToBottom(chatInfo.id, listState, chatModel.chatItems, scrollAdjustmentEnabled)
|
||||
ScrollToBottom(chatInfo.id, listState, chatModel.chatItems)
|
||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||
@@ -983,106 +961,21 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
|
||||
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
|
||||
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
|
||||
val sections = remember { derivedStateOf { reversedChatItems.value.putIntoSections(revealedItems.value) } }
|
||||
val preloadItemsEnabled = remember { mutableStateOf(true) }
|
||||
val boundaries = remember { derivedStateOf { sections.value.map { it.boundary } } }
|
||||
val scrollPosition: State<(Int) -> Int> = remember { mutableStateOf({ idx -> min(sections.value.revealedItemCount() - 1, idx + 1 ) }) }
|
||||
|
||||
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, preloadItemsEnabled, boundaries, loadMessages)
|
||||
|
||||
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() })
|
||||
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
|
||||
val chatInfoUpdated = rememberUpdatedState(chatInfo)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatInfoUpdated.value.id }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (revealedItems.value.isNotEmpty()) {
|
||||
revealedItems.value = setOf()
|
||||
}
|
||||
val firstUnreadItem = reversedChatItems.value.findLast { it.isRcvNew }
|
||||
if (firstUnreadItem != null) {
|
||||
val firstUnreadItemIndexIdx = sections.value.chatItemPosition(firstUnreadItem.id)
|
||||
if (firstUnreadItemIndexIdx != null) {
|
||||
scrollAdjustmentEnabled.value = false
|
||||
listState.scrollToItem(scrollPosition.value(firstUnreadItemIndexIdx), -maxHeight.value)
|
||||
}
|
||||
|
||||
if (chatModel.chatItemsSectionArea[firstUnreadItem.id] != ChatSectionArea.Bottom) {
|
||||
withBGApi {
|
||||
scrollAdjustmentEnabled.value = false
|
||||
try {
|
||||
apiLoadBottomSection(chatInfoUpdated.value, remoteHostId)
|
||||
} finally {
|
||||
delay(600)
|
||||
scrollAdjustmentEnabled.value = true
|
||||
preloadItemsEnabled.value = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollAdjustmentEnabled.value = true
|
||||
}
|
||||
}
|
||||
preloadItemsEnabled.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
val scrollToItem: State<(Long) -> Unit> = remember {
|
||||
mutableStateOf({ itemId: Long ->
|
||||
val index = sections.value.chatItemPosition(itemId)
|
||||
preloadItemsEnabled.value = false
|
||||
|
||||
if (index != null) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(scrollPosition.value(index), -maxHeight.value)
|
||||
preloadItemsEnabled.value = true
|
||||
}
|
||||
} else {
|
||||
withBGApi {
|
||||
try {
|
||||
val destinationSection = sections.value.find { it.boundary.area == ChatSectionArea.Destination }
|
||||
val itemsToDrop = destinationSection?.items?.flatMap { it.items }?.toList()
|
||||
withContext(Dispatchers.Main) {
|
||||
itemsToDrop?.forEach {
|
||||
chatModel.chatItemsSectionArea[it.id] = ChatSectionArea.Current
|
||||
}
|
||||
}
|
||||
val chatSectionLoader = ChatSectionLoader(0, ChatSectionArea.Destination)
|
||||
apiLoadMessages(
|
||||
rhId = remoteHostId,
|
||||
chatInfo = chatInfoUpdated.value,
|
||||
chatModel = chatModel,
|
||||
itemId = itemId,
|
||||
search = "",
|
||||
chatSectionLoader = chatSectionLoader,
|
||||
pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2)
|
||||
)
|
||||
val idx = sections.value.chatItemPosition(itemId)
|
||||
scope.launch {
|
||||
if (idx != null) {
|
||||
listState.animateScrollToItem(scrollPosition.value(idx), -maxHeight.value)
|
||||
if (!itemsToDrop.isNullOrEmpty()) {
|
||||
itemsToDrop.forEach {
|
||||
chatModel.chatItemsSectionArea.remove(it.id)
|
||||
}
|
||||
chatModel.chatItems.removeAll { chatModel.chatItemsSectionArea[it.id] == null }
|
||||
val newIdx = reversedChatItems.value.indexOfFirst { it.id == itemId }
|
||||
listState.scrollToItem(scrollPosition.value(newIdx), -maxHeight.value)
|
||||
}
|
||||
}
|
||||
preloadItemsEnabled.value = true
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
preloadItemsEnabled.value = true
|
||||
}
|
||||
mutableStateOf(
|
||||
{ itemId: Long ->
|
||||
val index = reversedChatItems.value.indexOfFirst { it.id == itemId }
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
|
||||
LaunchedEffect(chatInfo.id) {
|
||||
@@ -1100,14 +993,23 @@ fun BoxScope.ChatItemsList(
|
||||
VideoPlayerHolder.releaseAll()
|
||||
}
|
||||
)
|
||||
@Composable
|
||||
fun ChatViewListItem(i: Int, range: IntRange?, showAvatar: Boolean, cItem: ChatItem, prevItem: ChatItem?, nextItem: ChatItem?) {
|
||||
LazyColumnWithScrollBar(
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
|
||||
val itemScope = rememberCoroutineScope()
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
val itemScope = rememberCoroutineScope()
|
||||
val provider = {
|
||||
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
|
||||
itemScope.launch {
|
||||
@@ -1119,293 +1021,246 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
|
||||
val revealed = remember { mutableStateOf(revealedItems.value.contains(cItem.id)) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
|
||||
KeyChangeEffect(revealed.value) {
|
||||
val revealIds = if (range == null) setOf(cItem.id) else reversedChatItems.value.subList(range.first, range.last + 1).map { it.id }.toSet()
|
||||
|
||||
if (revealIds.isNotEmpty()) {
|
||||
if (revealed.value) {
|
||||
revealedItems.value = revealedItems.value.toMutableSet().apply { addAll(revealIds) }
|
||||
} else {
|
||||
revealedItems.value = revealedItems.value.toMutableSet().apply { removeAll(revealIds) }
|
||||
@Composable
|
||||
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) {
|
||||
tryOrShowError("${cItem.id}ChatItem", error = {
|
||||
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
}) {
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) {
|
||||
tryOrShowError("${cItem.id}ChatItem", error = {
|
||||
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
}) {
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
itemScope.launch {
|
||||
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
@Composable
|
||||
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
itemScope.launch {
|
||||
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val sent = cItem.chatDir.sent
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val sent = cItem.chatDir.sent
|
||||
|
||||
@Composable
|
||||
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
bottom = if (itemSeparation.largeGap) {
|
||||
if (i == 0) {
|
||||
8.dp
|
||||
} else {
|
||||
4.dp
|
||||
}
|
||||
} else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
content()
|
||||
@Composable
|
||||
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
bottom = if (itemSeparation.largeGap) {
|
||||
if (i == 0) {
|
||||
8.dp
|
||||
} else {
|
||||
4.dp
|
||||
}
|
||||
} else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
@Composable
|
||||
fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
|
||||
return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp)
|
||||
}
|
||||
|
||||
Box {
|
||||
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
|
||||
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
|
||||
val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
|
||||
val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() }
|
||||
if (chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val member = cItem.chatDir.groupMember
|
||||
val (prevMember, memCount) =
|
||||
if (range != null) {
|
||||
chatModel.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
null to 1
|
||||
}
|
||||
|
||||
if (showMemberImage(member, prevItem) || showAvatar) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.fillMaxWidth()
|
||||
.then(swipeableModifier),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
@Composable
|
||||
fun MemberNameAndRole() {
|
||||
Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
memberNames(member, prevMember, memCount),
|
||||
Modifier
|
||||
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
fontSize = 13.5.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp)
|
||||
}
|
||||
|
||||
Box {
|
||||
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
|
||||
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
|
||||
val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
|
||||
val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() }
|
||||
if (chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val member = cItem.chatDir.groupMember
|
||||
val (prevMember, memCount) =
|
||||
if (range != null) {
|
||||
chatModel.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
null to 1
|
||||
}
|
||||
if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.fillMaxWidth()
|
||||
.then(swipeableModifier),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
@Composable
|
||||
fun MemberNameAndRole() {
|
||||
Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
member.memberRole.text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
|
||||
memberNames(member, prevMember, memCount),
|
||||
Modifier
|
||||
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
fontSize = 13.5.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
|
||||
@Composable
|
||||
fun Item() {
|
||||
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
|
||||
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range, false)
|
||||
Text(
|
||||
member.memberRole.text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
|
||||
fontSize = 13.5.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cItem.content.showMemberName) {
|
||||
DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
|
||||
MemberNameAndRole()
|
||||
|
||||
@Composable
|
||||
fun Item() {
|
||||
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
|
||||
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cItem.content.showMemberName) {
|
||||
DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
|
||||
MemberNameAndRole()
|
||||
Item()
|
||||
}
|
||||
} else {
|
||||
Item()
|
||||
}
|
||||
} else {
|
||||
Item()
|
||||
}
|
||||
} else {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else { // direct message
|
||||
ChatItemBox {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp)
|
||||
Modifier.padding(
|
||||
start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp,
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false),
|
||||
)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
ChatItemBox {
|
||||
AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp,
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false),
|
||||
)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectionVisible) {
|
||||
Box(Modifier.matchParentSize().clickable {
|
||||
val checked = selectedChatItems.value?.contains(cItem.id) == true
|
||||
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
itemScope.launch {
|
||||
delay(600)
|
||||
val itemRange = if (range != null) {
|
||||
val firstItem = reversedChatItems.value.getOrNull(range.first)
|
||||
val lastItem = reversedChatItems.value.getOrNull(range.last)
|
||||
if (lastItem != null && firstItem != null) {
|
||||
CC.ItemRange(lastItem.id, firstItem.id)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
CC.ItemRange(cItem.id, cItem.id)
|
||||
}
|
||||
if (itemRange != null) {
|
||||
markRead(itemRange, null)
|
||||
if (selectionVisible) {
|
||||
Box(Modifier.matchParentSize().clickable {
|
||||
val checked = selectedChatItems.value?.contains(cItem.id) == true
|
||||
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val itemSeparation = getItemSeparation(cItem, nextItem)
|
||||
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
|
||||
|
||||
if (itemSeparation.date != null) {
|
||||
DateSeparator(itemSeparation.date)
|
||||
}
|
||||
|
||||
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
}
|
||||
LazyColumnWithScrollBar(
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
for (area in sections.value) {
|
||||
for ((sIdx, section) in area.items.withIndex()) {
|
||||
if (section.revealed) {
|
||||
itemsIndexed(section.items, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
|
||||
// index here is just temporary, should be removed at all or put in the section items
|
||||
val prevItem = area.getPreviousShownItem(sIdx, i)
|
||||
val nextItem = area.getNextShownItem(sIdx, i)
|
||||
ChatViewListItem(area.itemPositions[cItem.id] ?: -1, section.originalItemsRange.takeIf { cItem.mergeCategory != null }, section.showAvatar.contains(cItem.id), cItem, prevItem, nextItem, )
|
||||
}
|
||||
val (currIndex, nextItem) = chatModel.getNextChatItem(cItem)
|
||||
val ciCategory = cItem.mergeCategory
|
||||
if (ciCategory != null && ciCategory == nextItem?.mergeCategory) {
|
||||
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
|
||||
} else {
|
||||
val item = section.items.first()
|
||||
item(key = item.id to item.meta.createdAt.toEpochMilliseconds()) {
|
||||
// here you make one collapsed item from multiple items (should be already in section items)
|
||||
val prevItem = area.getPreviousShownItem(sIdx, section.items.lastIndex)
|
||||
val nextItem = area.getNextShownItem(sIdx, section.items.lastIndex)
|
||||
ChatViewListItem(area.itemPositions[item.id] ?: -1, section.originalItemsRange, section.showAvatar.contains(item.id), item, prevItem, nextItem)
|
||||
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
|
||||
|
||||
val itemSeparation = getItemSeparation(cItem, nextItem)
|
||||
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
|
||||
|
||||
if (itemSeparation.date != null) {
|
||||
DateSeparator(itemSeparation.date)
|
||||
}
|
||||
|
||||
val range = chatViewItemsRange(currIndex, prevHidden)
|
||||
val reversed = reversedChatItems.value
|
||||
if (revealed.value && range != null) {
|
||||
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
|
||||
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1]
|
||||
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
} else {
|
||||
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
|
||||
if (i == reversed.lastIndex) {
|
||||
DateSeparator(cItem.meta.itemTs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
itemScope.launch {
|
||||
delay(600)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reversedChatItems.value.isNotEmpty()) {
|
||||
item {
|
||||
DateSeparator(reversedChatItems.value.last().meta.itemTs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState) {
|
||||
preloadItemsEnabled.value = false
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
preloadItemsEnabled.value = true
|
||||
sections.value.dropTemporarySections()
|
||||
}
|
||||
}
|
||||
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState)
|
||||
|
||||
FloatingDate(
|
||||
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
|
||||
@@ -1421,13 +1276,13 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>, enabled: State<Boolean>) {
|
||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) }
|
||||
LaunchedEffect(chatId, shouldAutoScroll) {
|
||||
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0 && enabled.value) {
|
||||
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
// Don't autoscroll next time until it will be needed
|
||||
@@ -1441,7 +1296,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatItems.value.lastOrNull()?.id }
|
||||
.distinctUntilChanged()
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it && enabled.value }
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||
.collect {
|
||||
try {
|
||||
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) {
|
||||
@@ -1471,8 +1326,7 @@ fun BoxScope.FloatingButtons(
|
||||
chatInfo: ChatInfo,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
listState: LazyListState,
|
||||
scrollToLatestItem: () -> Unit
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
|
||||
@@ -1494,7 +1348,9 @@ fun BoxScope.FloatingButtons(
|
||||
showBottomButtonWithCounter,
|
||||
showBottomButtonWithArrow,
|
||||
composeViewHeight,
|
||||
onClickArrowDown = scrollToLatestItem,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
|
||||
@@ -1541,35 +1397,12 @@ fun PreloadItems(
|
||||
chatId: String,
|
||||
listState: LazyListState,
|
||||
remaining: Int = 10,
|
||||
enabled: State<Boolean>,
|
||||
boundaries: State<List<ChatSectionAreaBoundary>>,
|
||||
onLoadMore: (ChatId, ScrollDirection, Triple<Long, Int, ChatSectionArea>) -> Unit,
|
||||
onLoadMore: (ChatId) -> Unit,
|
||||
) {
|
||||
// Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection
|
||||
val allowLoad = remember { mutableStateOf(false) }
|
||||
val chatId = rememberUpdatedState(chatId)
|
||||
val onLoadMore = rememberUpdatedState(onLoadMore)
|
||||
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
|
||||
var previousIndex by remember { mutableStateOf(0) }
|
||||
var previousScrollOffset by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
val currentIndex = listState.firstVisibleItemIndex
|
||||
val currentScrollOffset = listState.firstVisibleItemScrollOffset
|
||||
val threshold = 25
|
||||
|
||||
scrollDirection = when {
|
||||
currentIndex > previousIndex -> ScrollDirection.Up
|
||||
currentIndex < previousIndex -> ScrollDirection.Down
|
||||
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Up
|
||||
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Down
|
||||
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
|
||||
else -> scrollDirection
|
||||
}
|
||||
|
||||
previousIndex = currentIndex
|
||||
previousScrollOffset = currentScrollOffset
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatId.value }
|
||||
.filterNotNull()
|
||||
@@ -1579,41 +1412,19 @@ fun PreloadItems(
|
||||
allowLoad.value = true
|
||||
}
|
||||
}
|
||||
KeyChangeEffect(allowLoad.value, enabled.value) {
|
||||
KeyChangeEffect(allowLoad.value) {
|
||||
snapshotFlow {
|
||||
val lInfo = listState.layoutInfo
|
||||
val totalItemsNumber = lInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
val section = if (scrollDirection == ScrollDirection.Up) {
|
||||
boundaries.value.find { lastVisibleItemIndex in it.minIndex..it.maxIndex }
|
||||
} else if (scrollDirection == ScrollDirection.Down) {
|
||||
boundaries.value.find { listState.firstVisibleItemIndex in it.minIndex..it.maxIndex }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val request = if (allowLoad.value && section != null && enabled.value) {
|
||||
val itemIdx = when {
|
||||
scrollDirection == ScrollDirection.Up && lastVisibleItemIndex > (section.maxIndex - remaining) -> {
|
||||
chatModel.chatItems.size - 1 - section.maxIndex
|
||||
}
|
||||
scrollDirection == ScrollDirection.Down && section.area != ChatSectionArea.Bottom && listState.firstVisibleItemIndex < (section.minIndex + remaining) && totalItemsNumber > remaining -> {
|
||||
chatModel.chatItems.size - 1 - section.minIndex
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
val itemId = itemIdx?.let { chatModel.chatItems.value.getOrNull(it)?.id }
|
||||
itemId?.let { Triple(it, itemIdx, section.area) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
request
|
||||
if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||
totalItemsNumber + ChatPagination.PRELOAD_COUNT
|
||||
else
|
||||
0
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.filter { it > 0 }
|
||||
.collect {
|
||||
onLoadMore.value(chatId.value, scrollDirection, it)
|
||||
onLoadMore.value(chatId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2291,7 +2102,7 @@ fun PreviewChatLayout() {
|
||||
back = {},
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadMessages = { _, _, _ -> },
|
||||
loadPrevMessages = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessages = { _ -> },
|
||||
receiveFile = { _ -> },
|
||||
@@ -2363,7 +2174,7 @@ fun PreviewGroupChatLayout() {
|
||||
back = {},
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadMessages = { _, _, _ -> },
|
||||
loadPrevMessages = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessages = {},
|
||||
receiveFile = { _ -> },
|
||||
|
||||
+8
@@ -381,6 +381,14 @@ fun ComposeView(
|
||||
|
||||
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
// val composedMessages = Array(300) { index ->
|
||||
// ComposedMessage(
|
||||
// file,
|
||||
// quoted,
|
||||
// MsgContent.MCText("$index")
|
||||
// )
|
||||
// }.toList()
|
||||
val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
|
||||
chatModel.controller.apiCreateChatItems(
|
||||
rh = chat.remoteHostId,
|
||||
|
||||
+2
-3
@@ -70,9 +70,8 @@ fun GroupMemberInfoView(
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
openDirectChat = {
|
||||
withBGApi {
|
||||
val r = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
if (r != null) {
|
||||
val (c) = r
|
||||
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
withChats {
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
addChat(c)
|
||||
|
||||
+1
-7
@@ -129,13 +129,7 @@ fun FramedItemView(
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = {
|
||||
if (qi.itemId == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_get_chat_item_not_found_title), generalGetString(MR.strings.failed_to_get_chat_item_not_found_description))
|
||||
} else {
|
||||
scrollToItem(qi.itemId)
|
||||
}
|
||||
}
|
||||
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
|
||||
+14
-27
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -31,7 +32,6 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -204,56 +204,43 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) {
|
||||
}
|
||||
|
||||
suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat.first, chatModel, chat.second)
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat.first, chatModel, chat.second)
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope {
|
||||
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null && isActive) {
|
||||
openLoadedChat(chat.first, chatModel, chat.second)
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
fun openLoadedChat(chat: Chat, chatModel: ChatModel, landingSection: ChatLandingSection = ChatLandingSection.Latest) {
|
||||
fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
chatModel.chatItemsSectionArea.clear()
|
||||
chatModel.chatItems.value.associateTo(chatModel.chatItemsSectionArea) { it.id to landingSectionToArea(landingSection) }
|
||||
}
|
||||
|
||||
suspend fun apiLoadMessages(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
chatModel: ChatModel,
|
||||
itemId: Long,
|
||||
search: String,
|
||||
chatSectionLoader: ChatSectionLoader,
|
||||
pagination: ChatPagination = ChatPagination.Before(itemId, ChatPagination.PRELOAD_COUNT)
|
||||
) {
|
||||
val (chat) = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
|
||||
val chatInfo = ch.chatInfo
|
||||
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
if (chatModel.chatId.value != chat.id) return
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemsToAdd = chatSectionLoader.prepareItems(chat.chatItems)
|
||||
if (itemsToAdd.isNotEmpty()) {
|
||||
chatModel.chatItems.addAll(chatSectionLoader.position, itemsToAdd)
|
||||
}
|
||||
}
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
|
||||
val chatInfo = ch.chatInfo
|
||||
val (chat) = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||
if (chatModel.chatId.value != chat.id) return
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
}
|
||||
|
||||
+2
-2
@@ -105,7 +105,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
|
||||
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
|
||||
if (toRemove.isNotEmpty()) {
|
||||
runAtomically { toRemove.removeAll { elem -> modalViews.removeAt(elem); true } }
|
||||
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
|
||||
}
|
||||
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
|
||||
// to prevent unneeded animation on different situations
|
||||
@@ -184,7 +184,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
}
|
||||
// This is needed because if we delete from modalViews immediately on request, animation will be bad
|
||||
if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) {
|
||||
runAtomically { toRemove.removeAll { elem -> modalViews.removeAt(elem); true } }
|
||||
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,6 @@
|
||||
<string name="error_loading_xftp_servers">Error loading XFTP servers</string>
|
||||
<string name="error_setting_network_config">Error updating network configuration</string>
|
||||
<string name="failed_to_parse_chat_title">Failed to load chat</string>
|
||||
<string name="failed_to_get_chat_item_not_found_title">Message no longer available</string>
|
||||
<string name="failed_to_get_chat_item_not_found_description">The quoted message you are trying to access has been deleted.</string>
|
||||
<string name="failed_to_parse_chats_title">Failed to load chats</string>
|
||||
<string name="contact_developers">Please update the app and contact developers.</string>
|
||||
<string name="failed_to_create_user_title">Error creating profile!</string>
|
||||
|
||||
@@ -643,7 +643,7 @@ getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId)
|
||||
where
|
||||
resp :: ChatResponse -> Maybe Contact
|
||||
resp = \case
|
||||
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct
|
||||
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct
|
||||
_ -> Nothing
|
||||
|
||||
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: a8471eed5be93e7c3741aa4742b24193c9a2d6f5
|
||||
tag: ffecf200d4874dfa34f6d15b269964c0115a54ca
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
+15
-17
@@ -8,25 +8,23 @@ layout: layouts/jobs.html
|
||||
|
||||
SimpleX Chat Ltd is a seed stage startup with a lot of user growth in 2022-2023, and a lot of exciting technical and product problems to solve to grow faster.
|
||||
|
||||
We currently have 4 full-time people in the team - all engineers, including the founder.
|
||||
|
||||
We want to add up to 3 people to the team.
|
||||
We currently have 6 full-time people in the team.
|
||||
|
||||
We want to add 2 people to the team.
|
||||
|
||||
## Who we are looking for
|
||||
|
||||
### Product/UI designer
|
||||
### Web designer & developer for a website contract
|
||||
|
||||
You will be designing the user experience and the interface of both the app and the website in collaboration with the team.
|
||||
You will work with the founder and a product marketing expert to convert the stories we want to tell our current and prospective users into interactive experiences.
|
||||
|
||||
The current focus of the app is privacy and security, but we hope to have the design that would support the feeling of psychological safety, enabling people to achieve the results in the smallest amount of time.
|
||||
You are an expert in creating interactive web experiences:
|
||||
- 15+ years of web development and design experience.
|
||||
- Passionate about communications, privacy and data ownership.
|
||||
- Competent using PhotoShop, 3D modelling, etc.
|
||||
- Competent in Web tech, including JavaScript, animations, etc.
|
||||
|
||||
You are an experienced and innovative product designer with:
|
||||
- 8+ years of user experience and visual design.
|
||||
- Expertise in typography and high sensitivity to colors.
|
||||
- Exceptional precision and attention to details.
|
||||
- Strong opinions (weakly held).
|
||||
- A strong empathy.
|
||||
We will NOT consider agencies or groups – it must be one person working on the project.
|
||||
|
||||
### Application Haskell engineer
|
||||
|
||||
@@ -34,13 +32,12 @@ You will work with the Haskell core of the client applications and with the netw
|
||||
|
||||
You are an expert in language models, databases and Haskell:
|
||||
- expert knowledge of SQL.
|
||||
- Haskell exception handling, concurrency, STM, type systems.
|
||||
- 8y+ of software engineering experience in complex projects,
|
||||
- Haskell strictness, exceptions, [concurrency](https://simonmar.github.io/pages/pcph.html), STM, [type systems](https://thinkingwithtypes.com).
|
||||
- 15y+ of software engineering experience in complex projects.
|
||||
- deep understanding of the common programming principles:
|
||||
- data structures, bits and bytes, text encoding.
|
||||
- software design and algorithms.
|
||||
- concurrency.
|
||||
- networking.
|
||||
- [functional software design](https://mitp-content-server.mit.edu/books/content/sectbyfn/books_pres_0/6515/sicp.zip/index.html) and algorithms.
|
||||
- protocols and networking.
|
||||
|
||||
## About you
|
||||
|
||||
@@ -48,6 +45,7 @@ You are an expert in language models, databases and Haskell:
|
||||
- already use SimpleX Chat to communicate with friends/family or participate in public SimpleX Chat groups.
|
||||
- passionate about privacy, security and communications.
|
||||
- interested to make contributions to SimpleX Chat open-source project in your free time before we hire you, as an extended test.
|
||||
- you founded (and probably failed) at least one startup, or spent more time working for yourself than being employed.
|
||||
|
||||
- **Exceptionally pragmatic, very fast and customer-focussed**:
|
||||
- care about the customers (aka users) and about the product we build much more than about the code quality, technology stack, etc.
|
||||
|
||||
Generated
+82
-29
@@ -156,11 +156,11 @@
|
||||
"ghc98X": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696643148,
|
||||
"narHash": "sha256-E02DfgISH7EvvNAu0BHiPvl1E5FGMDi0pWdNZtIBC9I=",
|
||||
"lastModified": 1715066704,
|
||||
"narHash": "sha256-F0EVR8x/fcpj1st+hz96Wdsz5uwVIOziGKAwRxLOYJw=",
|
||||
"ref": "ghc-9.8",
|
||||
"rev": "443e870d977b1ab6fc05f47a9a17bc49296adbd6",
|
||||
"revCount": 61642,
|
||||
"rev": "78a253543d466ac511a1664a3e6aff032ca684d5",
|
||||
"revCount": 61757,
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://gitlab.haskell.org/ghc/ghc"
|
||||
@@ -175,11 +175,11 @@
|
||||
"ghc99": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1697054644,
|
||||
"narHash": "sha256-kKarOuXUaAH3QWv7ASx+gGFMHaHKe0pK5Zu37ky2AL4=",
|
||||
"lastModified": 1726585445,
|
||||
"narHash": "sha256-IdwQBex4boY6s0Plj5+ixf36rfYSUyMdTWrztKvZH30=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "f383a242c76f90bcca8a4d7ee001dcb49c172a9a",
|
||||
"revCount": 62040,
|
||||
"rev": "7fd9e5e29ab54eb406880077463e8552e2ddd39a",
|
||||
"revCount": 67238,
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://gitlab.haskell.org/ghc/ghc"
|
||||
@@ -225,6 +225,8 @@
|
||||
"hls-2.2": "hls-2.2",
|
||||
"hls-2.3": "hls-2.3",
|
||||
"hls-2.4": "hls-2.4",
|
||||
"hls-2.5": "hls-2.5",
|
||||
"hls-2.6": "hls-2.6",
|
||||
"hpc-coveralls": "hpc-coveralls",
|
||||
"hydra": "hydra",
|
||||
"iserv-proxy": "iserv-proxy",
|
||||
@@ -238,16 +240,17 @@
|
||||
"nixpkgs-2205": "nixpkgs-2205",
|
||||
"nixpkgs-2211": "nixpkgs-2211",
|
||||
"nixpkgs-2305": "nixpkgs-2305",
|
||||
"nixpkgs-2311": "nixpkgs-2311",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"old-ghc-nix": "old-ghc-nix",
|
||||
"stackage": "stackage"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701163700,
|
||||
"narHash": "sha256-sOrewUS3LnzV09nGr7+3R6Q6zsgU4smJc61QsHq+4DE=",
|
||||
"lastModified": 1705833500,
|
||||
"narHash": "sha256-rUIr6JNbCedt1g4gVYVvE9t0oFU6FUspCA0DS5cA8Bg=",
|
||||
"owner": "input-output-hk",
|
||||
"repo": "haskell.nix",
|
||||
"rev": "2808bfe3e62e9eb4ee8974cd623a00e1611f302b",
|
||||
"rev": "d0c35e75cbbc6858770af42ac32b0b85495fbd71",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -328,16 +331,50 @@
|
||||
"hls-2.4": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696939266,
|
||||
"narHash": "sha256-VOMf5+kyOeOmfXTHlv4LNFJuDGa7G3pDnOxtzYR40IU=",
|
||||
"lastModified": 1699862708,
|
||||
"narHash": "sha256-YHXSkdz53zd0fYGIYOgLt6HrA0eaRJi9mXVqDgmvrjk=",
|
||||
"owner": "haskell",
|
||||
"repo": "haskell-language-server",
|
||||
"rev": "362fdd1293efb4b82410b676ab1273479f6d17ee",
|
||||
"rev": "54507ef7e85fa8e9d0eb9a669832a3287ffccd57",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "haskell",
|
||||
"ref": "2.4.0.0",
|
||||
"ref": "2.4.0.1",
|
||||
"repo": "haskell-language-server",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hls-2.5": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1701080174,
|
||||
"narHash": "sha256-fyiR9TaHGJIIR0UmcCb73Xv9TJq3ht2ioxQ2mT7kVdc=",
|
||||
"owner": "haskell",
|
||||
"repo": "haskell-language-server",
|
||||
"rev": "27f8c3d3892e38edaef5bea3870161815c4d014c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "haskell",
|
||||
"ref": "2.5.0.0",
|
||||
"repo": "haskell-language-server",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hls-2.6": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1705325287,
|
||||
"narHash": "sha256-+P87oLdlPyMw8Mgoul7HMWdEvWP/fNlo8jyNtwME8E8=",
|
||||
"owner": "haskell",
|
||||
"repo": "haskell-language-server",
|
||||
"rev": "6e0b342fa0327e628610f2711f8c3e4eaaa08b1e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "haskell",
|
||||
"ref": "2.6.0.0",
|
||||
"repo": "haskell-language-server",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -384,11 +421,11 @@
|
||||
"iserv-proxy": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1691634696,
|
||||
"narHash": "sha256-MZH2NznKC/gbgBu8NgIibtSUZeJ00HTLJ0PlWKCBHb0=",
|
||||
"lastModified": 1707968597,
|
||||
"narHash": "sha256-C53NqToxl+n9s1pQ0iLtiH6P5vX3rM+NW/mFt4Ykpsk=",
|
||||
"ref": "hkm/remote-iserv",
|
||||
"rev": "43a979272d9addc29fbffc2e8542c5d96e993d73",
|
||||
"revCount": 14,
|
||||
"rev": "1b7f8aeb37bbc7c00f04e44d9379aa15a4409e8b",
|
||||
"revCount": 18,
|
||||
"type": "git",
|
||||
"url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git"
|
||||
},
|
||||
@@ -552,11 +589,11 @@
|
||||
},
|
||||
"nixpkgs-2305": {
|
||||
"locked": {
|
||||
"lastModified": 1695416179,
|
||||
"narHash": "sha256-610o1+pwbSu+QuF3GE0NU5xQdTHM3t9wyYhB9l94Cd8=",
|
||||
"lastModified": 1705033721,
|
||||
"narHash": "sha256-K5eJHmL1/kev6WuqyqqbS1cdNnSidIZ3jeqJ7GbrYnQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "715d72e967ec1dd5ecc71290ee072bcaf5181ed6",
|
||||
"rev": "a1982c92d8980a0114372973cbdfe0a307f1bdea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -566,6 +603,22 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-2311": {
|
||||
"locked": {
|
||||
"lastModified": 1719957072,
|
||||
"narHash": "sha256-gvFhEf5nszouwLAkT9nWsDzocUTqLWHuL++dvNjMp9I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7144d6241f02d171d25fba3edeaf15e0f2592105",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-23.11-darwin",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"dir": "lib",
|
||||
@@ -602,17 +655,17 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1695318763,
|
||||
"narHash": "sha256-FHVPDRP2AfvsxAdc+AsgFJevMz5VBmnZglFUMlxBkcY=",
|
||||
"lastModified": 1694822471,
|
||||
"narHash": "sha256-6fSDCj++lZVMZlyqOe9SIOL8tYSBz1bI8acwovRwoX8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e12483116b3b51a185a33a272bf351e357ba9a99",
|
||||
"rev": "47585496bcb13fb72e4a90daeea2f434e2501998",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47585496bcb13fb72e4a90daeea2f434e2501998",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@@ -664,11 +717,11 @@
|
||||
"stackage": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1699834215,
|
||||
"narHash": "sha256-g/JKy0BCvJaxPuYDl3QVc4OY8cFEomgG+hW/eEV470M=",
|
||||
"lastModified": 1726532152,
|
||||
"narHash": "sha256-LRXbVY3M2S8uQWdwd2zZrsnVPEvt2GxaHGoy8EFFdJA=",
|
||||
"owner": "input-output-hk",
|
||||
"repo": "stackage.nix",
|
||||
"rev": "47aacd04abcce6bad57f43cbbbd133538380248e",
|
||||
"rev": "c77b3530cebad603812cb111c6f64968c2d2337d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [
|
||||
pkgs.pkgsCross.mingwW64.openssl
|
||||
];
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
pkgs.pkgsCross.mingwW64.openssl
|
||||
];
|
||||
@@ -335,6 +336,7 @@
|
||||
packages.direct-sqlcipher.patches = [
|
||||
./scripts/nix/direct-sqlcipher-android-log.patch
|
||||
];
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
(android32Pkgs.openssl.override { static = true; enableKTLS = false; })
|
||||
];
|
||||
@@ -443,6 +445,7 @@
|
||||
packages.direct-sqlcipher.patches = [
|
||||
./scripts/nix/direct-sqlcipher-android-log.patch
|
||||
];
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
(androidPkgs.openssl.override { static = true; })
|
||||
];
|
||||
@@ -547,6 +550,7 @@
|
||||
packages.simplexmq.flags.swift = true;
|
||||
packages.direct-sqlcipher.flags.commoncrypto = true;
|
||||
packages.entropy.flags.DoNotGetEntropy = true;
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
# TODO: have a cross override for iOS, that sets this.
|
||||
((pkgs.openssl.override { static = true; }).overrideDerivation (old: { CFLAGS = "-mcpu=apple-a7 -march=armv8-a+norcpc" ;}))
|
||||
@@ -561,6 +565,7 @@
|
||||
extra-modules = [{
|
||||
packages.direct-sqlcipher.flags.commoncrypto = true;
|
||||
packages.entropy.flags.DoNotGetEntropy = true;
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
((pkgs.openssl.override { static = true; }).overrideDerivation (old: { CFLAGS = "-mcpu=apple-a7 -march=armv8-a+norcpc" ;}))
|
||||
];
|
||||
@@ -578,6 +583,7 @@
|
||||
packages.simplexmq.flags.swift = true;
|
||||
packages.direct-sqlcipher.flags.commoncrypto = true;
|
||||
packages.entropy.flags.DoNotGetEntropy = true;
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
(pkgs.openssl.override { static = true; })
|
||||
];
|
||||
@@ -591,6 +597,7 @@
|
||||
extra-modules = [{
|
||||
packages.direct-sqlcipher.flags.commoncrypto = true;
|
||||
packages.entropy.flags.DoNotGetEntropy = true;
|
||||
packages.simplexmq.flags.client_library = true;
|
||||
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
|
||||
(pkgs.openssl.override { static = true; })
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc
|
||||
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
|
||||
|
||||
rm -rf $BUILD_DIR
|
||||
cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded'
|
||||
cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded' --constraint 'simplexmq +client_library'
|
||||
cd $BUILD_DIR/build
|
||||
#patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so
|
||||
#patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so
|
||||
|
||||
@@ -24,7 +24,7 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc
|
||||
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
|
||||
|
||||
rm -rf $BUILD_DIR
|
||||
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi"
|
||||
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library'
|
||||
|
||||
cd $BUILD_DIR/build
|
||||
mkdir deps 2> /dev/null || true
|
||||
|
||||
@@ -51,7 +51,7 @@ echo " ghc-options: -shared -threaded -optl-L$openssl_windows_style_path -opt
|
||||
# Very important! Without it the build fails on linking step since the linker can't find exported symbols.
|
||||
# It looks like GHC bug because with such random path the build ends successfully
|
||||
sed -i "s/ld.lld.exe/abracadabra.exe/" `ghc --print-libdir`/settings
|
||||
cabal build lib:simplex-chat
|
||||
cabal build lib:simplex-chat --constraint 'simplexmq +client_library'
|
||||
|
||||
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
rm -rf apps/multiplatform/desktop/build/cmake
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."a8471eed5be93e7c3741aa4742b24193c9a2d6f5" = "093i40api0dp7rvw6f1f3pww3q5iv6mvbj577nlxp3qqcbvyh6fs";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."ffecf200d4874dfa34f6d15b269964c0115a54ca" = "0kb8hq37fc5g198wq7dswnlwjzk67q8rrzil2dii5lc6xfr47jbs";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+6
-6
@@ -735,14 +735,14 @@ processChatCommand' vr = \case
|
||||
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
|
||||
-- TODO optimize queries calculating ChatStats, currently they're disabled
|
||||
CTDirect -> do
|
||||
(directChat, section) <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTDirect directChat) section
|
||||
directChat <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTDirect directChat)
|
||||
CTGroup -> do
|
||||
(groupChat, section) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTGroup groupChat) section
|
||||
groupChat <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTGroup groupChat)
|
||||
CTLocal -> do
|
||||
(localChat, section) <- withFastStore (\db -> getLocalChat db user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTLocal localChat) section
|
||||
localChat <- withFastStore (\db -> getLocalChat db user cId pagination search)
|
||||
pure $ CRApiChat user (AChat SCTLocal localChat)
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
APIGetChatItems pagination search -> withUser $ \user -> do
|
||||
|
||||
@@ -572,7 +572,7 @@ data ChatResponse
|
||||
| CRChatSuspended
|
||||
| CRApiChats {user :: User, chats :: [AChat]}
|
||||
| CRChats {chats :: [AChat]}
|
||||
| CRApiChat {user :: User, chat :: AChat, section :: ChatLandingSection}
|
||||
| CRApiChat {user :: User, chat :: AChat}
|
||||
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
|
||||
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
|
||||
| CRChatItemId User (Maybe ChatItemId)
|
||||
@@ -843,11 +843,6 @@ data ChatPagination
|
||||
| CPInitial Int
|
||||
deriving (Show)
|
||||
|
||||
data ChatLandingSection
|
||||
= CLSLatest
|
||||
| CLSUnread
|
||||
deriving (Show, Eq)
|
||||
|
||||
data PaginationByTime
|
||||
= PTLast Int
|
||||
| PTAfter UTCTime Int
|
||||
@@ -1598,8 +1593,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
|
||||
|
||||
$(JQ.deriveJSON (enumJSON $ dropPrefix "CLS") ''ChatLandingSection)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
|
||||
|
||||
$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE KindSignatures #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PatternSynonyms #-}
|
||||
@@ -138,7 +139,7 @@ import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
import Simplex.Chat.Controller (ChatLandingSection (CLSLatest, CLSUnread), ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
|
||||
import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
@@ -947,15 +948,15 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of
|
||||
aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats
|
||||
in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat
|
||||
|
||||
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, ChatLandingSection)
|
||||
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChat db vr user contactId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
ct <- getContact db vr user contactId
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getDirectChatLast_ db user ct count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getDirectChatAfter_ db user ct afterId count search
|
||||
CPBefore beforeId count -> (,CLSLatest) <$> getDirectChatBefore_ db user ct beforeId count search
|
||||
CPAround aroundId count -> (,CLSLatest) <$> getDirectChatAround_ db user ct aroundId count search
|
||||
CPLast count -> liftIO $ getDirectChatLast_ db user ct count search
|
||||
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
|
||||
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
|
||||
CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search
|
||||
CPInitial count -> do
|
||||
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
|
||||
getDirectChatInitial_ db user ct count
|
||||
@@ -1081,20 +1082,28 @@ getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search =
|
||||
beforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
|
||||
afterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetDirectItem db user ct currentTs) beforeIds
|
||||
beforeChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) beforeIds
|
||||
afterChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) afterIds
|
||||
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
|
||||
pure $ Chat (DirectChat ct) chatItems stats
|
||||
let remainingAfter = fetchCountAfter - length afterIds
|
||||
let remainingBefore = fetchCountBefore - length beforeIds
|
||||
if
|
||||
| remainingBefore > 0 && remainingAfter <= 0 -> do
|
||||
extraAfterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
|
||||
extraAfterItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraAfterIds
|
||||
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
|
||||
| remainingAfter > 0 && remainingBefore <= 0 -> do
|
||||
extraBeforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
|
||||
extraBeforeItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraBeforeIds
|
||||
pure $ Chat (DirectChat ct) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
|
||||
| otherwise ->
|
||||
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
|
||||
|
||||
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect, ChatLandingSection)
|
||||
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
|
||||
firstUnreadItemId_ <- liftIO getDirectChatMinUnreadItemId_
|
||||
case firstUnreadItemId_ of
|
||||
Just firstUnreadItemId -> do
|
||||
chat <- getDirectChatAround_ db user ct firstUnreadItemId count ""
|
||||
lastItemId <- liftIO $ getDirectChatItemIdsLast_ db user ct 1 ""
|
||||
pure (chat, landingSection chat lastItemId)
|
||||
Nothing -> liftIO $ (,CLSLatest) <$> getDirectChatLast_ db user ct count ""
|
||||
Just firstUnreadItemId -> getDirectChatAround_ db user ct firstUnreadItemId count ""
|
||||
Nothing -> liftIO $ getDirectChatLast_ db user ct count ""
|
||||
where
|
||||
getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getDirectChatMinUnreadItemId_ =
|
||||
@@ -1108,21 +1117,15 @@ getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
|
||||
|]
|
||||
(userId, contactId, CISRcvNew)
|
||||
|
||||
landingSection :: Chat c -> [ChatItemId] -> ChatLandingSection
|
||||
landingSection Chat {chatItems} [lastItemId] = do
|
||||
let lastItemIdInChat = foldr (\ci acc -> acc || cchatItemId ci == lastItemId) False chatItems
|
||||
if lastItemIdInChat then CLSLatest else CLSUnread
|
||||
landingSection _ _ = CLSUnread
|
||||
|
||||
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, ChatLandingSection)
|
||||
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
|
||||
getGroupChat db vr user groupId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
g <- getGroupInfo db vr user groupId
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getGroupChatLast_ db user g count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getGroupChatAfter_ db user g afterId count search
|
||||
CPBefore beforeId count -> (,CLSLatest) <$> getGroupChatBefore_ db user g beforeId count search
|
||||
CPAround aroundId count -> (,CLSLatest) <$> getGroupChatAround_ db user g aroundId count search
|
||||
CPLast count -> liftIO $ getGroupChatLast_ db user g count search
|
||||
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
|
||||
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
|
||||
CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search
|
||||
CPInitial count -> do
|
||||
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
|
||||
getGroupChatInitial_ db user g count
|
||||
@@ -1247,20 +1250,28 @@ getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
|
||||
beforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g aroundItemId fetchCountBefore search (chatItemTs middleChatItem)
|
||||
afterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g aroundItemId fetchCountAfter search (chatItemTs middleChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetGroupItem db user g currentTs) beforeIds
|
||||
beforeChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) beforeIds
|
||||
afterChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) afterIds
|
||||
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
|
||||
pure $ Chat (GroupChat g) chatItems stats
|
||||
let remainingAfter = fetchCountAfter - length afterIds
|
||||
let remainingBefore = fetchCountBefore - length beforeIds
|
||||
if
|
||||
| remainingBefore > 0 && remainingAfter <= 0 -> do
|
||||
extraAfterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g (last afterIds) remainingBefore search (chatItemTs (last afterChatItems))
|
||||
extraAfterItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraAfterIds
|
||||
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
|
||||
| remainingAfter > 0 && remainingBefore <= 0 -> do
|
||||
extraBeforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g (last beforeIds) remainingAfter search (chatItemTs (last beforeChatItems))
|
||||
extraBeforeItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraBeforeIds
|
||||
pure $ Chat (GroupChat g) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
|
||||
| otherwise ->
|
||||
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
|
||||
|
||||
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, ChatLandingSection)
|
||||
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup)
|
||||
getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
|
||||
firstUnreadItemId_ <- liftIO getGroupChatMinUnreadItemId_
|
||||
case firstUnreadItemId_ of
|
||||
Just firstUnreadItemId -> do
|
||||
chat <- getGroupChatAround_ db user g firstUnreadItemId count ""
|
||||
lastItemId <- liftIO $ getGroupChatItemIdsLast_ db user g 1 ""
|
||||
pure (chat, landingSection chat lastItemId)
|
||||
Nothing -> liftIO $ (,CLSLatest) <$> getGroupChatLast_ db user g count ""
|
||||
Just firstUnreadItemId -> getGroupChatAround_ db user g firstUnreadItemId count ""
|
||||
Nothing -> liftIO $ getGroupChatLast_ db user g count ""
|
||||
where
|
||||
getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getGroupChatMinUnreadItemId_ =
|
||||
@@ -1274,15 +1285,15 @@ getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
|
||||
|]
|
||||
(userId, groupId, CISRcvNew)
|
||||
|
||||
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, ChatLandingSection)
|
||||
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
getLocalChat db user folderId pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
nf <- getNoteFolder db user folderId
|
||||
case pagination of
|
||||
CPLast count -> liftIO $ (,CLSLatest) <$> getLocalChatLast_ db user nf count search
|
||||
CPAfter afterId count -> (,CLSLatest) <$> getLocalChatAfter_ db user nf afterId count search
|
||||
CPBefore beforeId count -> (,CLSLatest) <$> getLocalChatBefore_ db user nf beforeId count search
|
||||
CPAround aroundId count -> (,CLSLatest) <$> getLocalChatAround_ db user nf aroundId count search
|
||||
CPLast count -> liftIO $ getLocalChatLast_ db user nf count search
|
||||
CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
|
||||
CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
|
||||
CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search
|
||||
CPInitial count -> do
|
||||
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
|
||||
getLocalChatInitial_ db user nf count
|
||||
@@ -1391,20 +1402,37 @@ getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count sear
|
||||
beforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
|
||||
afterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
beforeChatItems <- liftIO $ reverse <$> mapM (safeGetLocalItem db user nf currentTs) beforeIds
|
||||
beforeChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) beforeIds
|
||||
afterChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) afterIds
|
||||
let chatItems = beforeChatItems <> [middleChatItem] <> afterChatItems
|
||||
pure $ Chat (LocalChat nf) chatItems stats
|
||||
let remainingAfter = fetchCountAfter - length afterIds
|
||||
let remainingBefore = fetchCountBefore - length beforeIds
|
||||
if
|
||||
| remainingBefore > 0 && remainingAfter <= 0 -> do
|
||||
extraAfterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
|
||||
extraAfterItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraAfterIds
|
||||
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
|
||||
| remainingAfter > 0 && remainingBefore <= 0 -> do
|
||||
extraBeforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
|
||||
extraBeforeItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraBeforeIds
|
||||
pure $ Chat (LocalChat nf) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
|
||||
| otherwise ->
|
||||
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
|
||||
|
||||
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal, ChatLandingSection)
|
||||
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal)
|
||||
getLocalChatInitial_ db user@User {userId} nf@NoteFolder {noteFolderId} count = do
|
||||
firstUnreadItemId_ <- liftIO getLocalChatMinUnreadItemId_
|
||||
case firstUnreadItemId_ of
|
||||
Just firstUnreadItemId -> do
|
||||
chat <- getLocalChatAround_ db user nf firstUnreadItemId count ""
|
||||
lastItemId <- liftIO $ getLocalChatItemIdsLast_ db user nf 1 ""
|
||||
pure (chat, landingSection chat lastItemId)
|
||||
Nothing -> liftIO $ (,CLSLatest) <$> getLocalChatLast_ db user nf count ""
|
||||
let items = chatItems chat
|
||||
if null items || length items == count
|
||||
then pure chat
|
||||
else do
|
||||
let remainingCount = count - length items
|
||||
let afterId = cchatItemId $ last items
|
||||
after <- getLocalChatAfter_ db user nf afterId remainingCount ""
|
||||
pure $ chat {chatItems = chatItems chat <> chatItems after}
|
||||
Nothing -> liftIO $ getLocalChatLast_ db user nf count ""
|
||||
where
|
||||
getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
|
||||
getLocalChatMinUnreadItemId_ =
|
||||
|
||||
@@ -93,7 +93,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRChatSuspended -> ["chat suspended"]
|
||||
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
|
||||
CRChats chats -> viewChats ts tz chats
|
||||
CRApiChat u chat lsec -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] <> (["newer messages available" | lsec == CLSUnread])
|
||||
CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
|
||||
CRApiParsedMarkdown ft -> [viewJSON ft]
|
||||
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
|
||||
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
|
||||
|
||||
@@ -51,6 +51,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion)
|
||||
import Simplex.Messaging.Server (runSMPServerBlocking)
|
||||
import Simplex.Messaging.Server.Env.STM
|
||||
import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..))
|
||||
import Simplex.Messaging.Transport
|
||||
import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig)
|
||||
import Simplex.Messaging.Version
|
||||
@@ -424,6 +425,9 @@ smpServerCfg =
|
||||
tbqSize = 1,
|
||||
-- serverTbqSize = 1,
|
||||
msgQueueQuota = 16,
|
||||
msgStoreType = AMSType SMSMemory,
|
||||
maxJournalMsgCount = 1000,
|
||||
maxJournalStateLines = 1000,
|
||||
queueIdBytes = 12,
|
||||
msgIdBytes = 6,
|
||||
storeLogFile = Nothing,
|
||||
|
||||
@@ -66,6 +66,7 @@ chatDirectTests = do
|
||||
it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact
|
||||
it "should send multiline message" testMultilineMessage
|
||||
it "send large message" testLargeMessage
|
||||
it "initial chat pagination" testChatPaginationInitial
|
||||
describe "batch send messages" $ do
|
||||
it "send multiple messages api" testSendMulti
|
||||
it "send multiple timed messages" testSendMultiTimed
|
||||
@@ -361,6 +362,49 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
let itemIds = intercalate "," $ map show [i - 3 .. i]
|
||||
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
|
||||
|
||||
testChatPaginationInitial :: HasCallStack => FilePath -> IO ()
|
||||
testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
|
||||
connectUsers alice bob
|
||||
-- Wait, otherwise ids are going to be wrong.
|
||||
threadDelay 1000000
|
||||
|
||||
-- Send messages from alice to bob
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("@bob " <> show n)
|
||||
|
||||
-- Bob receives the messages.
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
|
||||
|
||||
-- All messages are unread for bob, should return area around unread
|
||||
bob #$> ("/_get chat @2 initial=3", chat, [(0, "Audio/video calls: enabled"), (0, "1"), (0, "2")])
|
||||
|
||||
-- Read next 2 items
|
||||
let itemIds = intercalate "," $ map itemId [1 .. 2]
|
||||
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
|
||||
bob #$> ("/_get chat @2 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
|
||||
|
||||
-- Read all items
|
||||
bob #$> ("/_read chat @2", id, "ok")
|
||||
bob #$> ("/_get chat @2 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
|
||||
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
|
||||
|
||||
-- Clear chat, send a few extra message and assert page size is consistent
|
||||
bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY")
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("@bob " <> show n)
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
|
||||
|
||||
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
|
||||
let newItemIds = intercalate "," $ map itemId [11 .. 12] -- Read, 1, 2
|
||||
bob #$> ("/_read chat items @2 " <> newItemIds, id, "ok")
|
||||
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
|
||||
let allButLastId = intercalate "," $ map itemId [13 .. 19] -- Read all but last
|
||||
bob #$> ("/_read chat items @2 " <> allButLastId, id, "ok")
|
||||
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
|
||||
where
|
||||
opts =
|
||||
testOpts
|
||||
{ markRead = False
|
||||
}
|
||||
|
||||
testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO ()
|
||||
testDuplicateContactsSeparate =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
@@ -36,6 +36,7 @@ chatGroupTests = do
|
||||
describe "chat groups" $ do
|
||||
describe "add contacts, create group and send/receive messages" testGroupMatrix
|
||||
it "mark multiple messages as read" testMarkReadGroup
|
||||
it "initial chat pagination" testChatPaginationInitial
|
||||
it "v1: add contacts, create group and send/receive messages" testGroup
|
||||
it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages
|
||||
it "send large message" testGroupLargeMessage
|
||||
@@ -375,6 +376,51 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
let itemIds = intercalate "," $ map show [i - 3 .. i]
|
||||
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
|
||||
|
||||
testChatPaginationInitial :: HasCallStack => FilePath -> IO ()
|
||||
testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
-- Wait, otherwise ids are going to be wrong.
|
||||
threadDelay 1000000
|
||||
lastEventId <- (read :: String -> Int) <$> lastItemId bob
|
||||
let groupItemId n = show $ lastEventId + n
|
||||
|
||||
-- Send messages from alice to bob
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("#team " <> show n)
|
||||
|
||||
-- Bob receives the messages.
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
|
||||
|
||||
-- All messages are unread for bob, should return area around unread
|
||||
bob #$> ("/_get chat #1 initial=3", chat, [(0, "connected"), (0, "1"), (0, "2")])
|
||||
|
||||
-- Read next 2 items
|
||||
let itemIds = intercalate "," $ map groupItemId [1 .. 2]
|
||||
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
|
||||
bob #$> ("/_get chat #1 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
|
||||
|
||||
-- Read all items
|
||||
bob #$> ("/_read chat #1", id, "ok")
|
||||
bob #$> ("/_get chat #1 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
|
||||
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
|
||||
|
||||
-- Clear chat, send a few extra message and assert page size is consistent
|
||||
bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("#team " <> show n)
|
||||
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
|
||||
|
||||
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
|
||||
let newItemIds = intercalate "," $ map groupItemId [11 .. 12] -- Read, 1, 2
|
||||
bob #$> ("/_read chat items #1 " <> newItemIds, id, "ok")
|
||||
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
|
||||
let allButLastId = intercalate "," $ map groupItemId [13 .. 19] -- Read all but last
|
||||
bob #$> ("/_read chat items #1 " <> allButLastId, id, "ok")
|
||||
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
|
||||
where
|
||||
opts =
|
||||
testOpts
|
||||
{ markRead = False
|
||||
}
|
||||
|
||||
testGroupLargeMessage :: HasCallStack => FilePath -> IO ()
|
||||
testGroupLargeMessage =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
Reference in New Issue
Block a user