Compare commits

..

10 Commits

Author SHA1 Message Date
Diogo 009968b7d4 open on unread 2024-11-05 15:50:55 +00:00
Diogo e1f24e7588 initial api integration and types 2024-11-05 10:28:18 +00:00
Diogo d8d990a437 Merge branch 'dc/core-pagination' into dc/core-initial-landing-for-chat 2024-11-04 22:44:35 +00:00
Diogo 65c136c7fb Merge branch 'master' into dc/core-pagination 2024-11-04 22:43:57 +00:00
Diogo 83875a35f8 final 2024-11-04 22:25:38 +00:00
Diogo d3d3b9aba9 gap size wip (needs testing) 2024-11-04 15:13:04 +00:00
Evgeny Poberezkin 7a741e7ac4 ios: update core library 2024-11-02 20:03:27 +00:00
Alexander Bondarenko 165143a111 Use simplexmq with client_library flag (#5133)
* Use simplexmq with client_library flag

* fix server config for mq master

* simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-11-02 17:51:11 +00:00
Evgeny ceb17b23b4 bumped haskell.nix (#5134)
Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2024-11-02 15:28:41 +00:00
Evgeny Poberezkin 3c8c9d8b52 website: update jobs page 2024-11-02 13:43:45 +00:00
31 changed files with 692 additions and 932 deletions
+1 -1
View File
@@ -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) && \
+22 -6
View File
@@ -320,15 +320,24 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
let loadItemsPerPage = 50
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))
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> (Chat, Int?) {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat, gap) = r { return (Chat.init(chat), gap) }
throw r
}
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> ([ChatItem], Int?) {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
if case let .apiChat(_, chat) = r { return chat.chatItems }
if case let .apiChat(_, chat, gap) = r { return (chat.chatItems, gap) }
if case .chatCmdError(_, _) = r {
if case .chatError(_, let chatError) = r {
if case .errorStore(let storeError) = chatError {
if case .chatItemNotFound(_) = storeError {
itemNotFoundAlert()
}
}
}
}
throw r
}
@@ -341,7 +350,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
if clearItems {
await MainActor.run { im.reversedChatItems = [] }
}
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
let (chat, _) = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
@@ -418,6 +427,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,42 @@ 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
}
}
private func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) async -> [ChatItem]? {
do {
var reversedPage = Array<ChatItem>()
let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2)
let (chatItems, _) = try await apiGetChatItems(
type: cInfo.chatType,
id: cInfo.apiId,
pagination: pagination,
search: ""
)
reversedPage.append(contentsOf: chatItems.reversed())
await MainActor.run {
ItemsModel.shared.reversedChatItems.append(contentsOf: reversedPage)
}
return reversedPage
} catch let error {
logger.error("apiGetChat error: \(responseError(error))")
return nil
}
}
}
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
+19 -1
View File
@@ -363,6 +363,11 @@ struct ChatView: View {
}
}
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
Task {
if let firstunreadItem = self.getFirstUnreadItem() {
scrollModel.scrollToUnread(id: firstunreadItem.id)
}
}
}
private func searchToolbar() -> some View {
@@ -471,6 +476,19 @@ struct ChatView: View {
EmptyView()
}
}
private func getFirstUnreadItem() -> ChatItem? {
logger.error("[scrolling] \(im.reversedChatItems.count)")
for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) {
let item = im.reversedChatItems[i]
if item.isRcvNew {
logger.error("[scrolling] First unread item: \(item.text)")
return item
}
}
return nil
}
class FloatingButtonModel: ObservableObject {
static let shared = FloatingButtonModel()
@@ -854,7 +872,7 @@ struct ChatView: View {
} else {
.last(count: loadItemsPerPage)
}
let chatItems = try await apiGetChatItems(
let (chatItems, _) = try await apiGetChatItems(
type: cInfo.chatType,
id: cInfo.apiId,
pagination: pagination,
@@ -340,7 +340,7 @@ struct GroupMemberInfoView: View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
let (chat, _) = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
+28 -2
View File
@@ -36,8 +36,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
case let .unread(id):
controller.scrollToUnread(to: items.firstIndex(where: { $0.id == id }))
}
} else {
logger.error("[scrolling] not scrolling")
controller.update(items: items)
}
}
@@ -78,7 +81,8 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 {
if indexPath.item > self.itemCount - 8, self.representer.scrollState == .atDestination {
logger.error("[scrolling] requesting page")
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
@@ -161,6 +165,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
)
Task { representer.scrollState = .atDestination }
}
func scrollToUnread(to index: Int?) {
if let index = index {
if isVisible(indexPath: IndexPath(row: index, section: 0)) {
self.scroll(to: 0, position: .top)
} else {
self.scroll(to: index, position: .bottom)
}
} else {
self.scroll(to: index, position: .bottom)
}
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
@@ -169,7 +185,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
if let index, tableView.numberOfRows(inSection: 0) != 0, !isVisible(indexPath: IndexPath(row: index, section: 0)) {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
@@ -294,6 +310,7 @@ class ReverseListScrollModel: ObservableObject {
case nextPage
case item(ChatItem.ID)
case bottom
case unread(ChatItem.ID)
}
case scrollingTo(Destination)
@@ -303,16 +320,25 @@ class ReverseListScrollModel: ObservableObject {
@Published var state: State = .atDestination
func scrollToNextPage() {
logger.error("[scrolling] to next page")
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
logger.error("[scrolling] to bottom")
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
logger.error("[scrolling] to item \(id)")
state = .scrollingTo(.item(id))
}
func scrollToUnread(id: ChatItem.ID) {
logger.error("[scrolling] to unread")
state = .scrollingTo(.unread(id))
}
}
fileprivate let cellReuseId = "hostingCell"
+8 -8
View File
@@ -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 */; };
@@ -492,9 +492,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>"; };
@@ -663,8 +663,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;
};
@@ -815,8 +815,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 */,
+2 -1
View File
@@ -214,7 +214,8 @@ public func chatResponse(_ s: String) -> ChatResponse {
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
let jChat = jApiChat["chat"] as? NSDictionary,
let chat = try? parseChatData(jChat) {
return .apiChat(user: user, chat: chat)
let gap = jApiChat["gap"] as? Int
return .apiChat(user: user, chat: chat, gap: gap)
}
} else if type == "chatCmdError" {
if let jError = jResp["chatCmdError"] as? NSDictionary {
+7 -3
View File
@@ -546,7 +546,7 @@ public enum ChatResponse: Decodable, Error {
case chatStopped
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case apiChat(user: UserRef, chat: ChatData, gap: Int?)
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: UserRef, servers: UserProtoServers)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
@@ -888,7 +888,7 @@ public enum ChatResponse: Decodable, Error {
case .chatStopped: return noDetails
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .apiChat(u, chat, gap): return withUser(u, "gap: \(String(describing: gap)) \(String(describing: chat))")
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
@@ -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)"
}
}
}
@@ -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() }
}
@@ -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?
@@ -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)
}
}
@@ -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 = { _ -> },
@@ -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)
@@ -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 }
) {
@@ -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)
}
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+7
View File
@@ -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; })
];
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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, gap) <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTDirect directChat) gap
CTGroup -> do
(groupChat, section) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) section
(groupChat, gap) <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) gap
CTLocal -> do
(localChat, section) <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) section
(localChat, gap) <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) gap
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIGetChatItems pagination search -> withUser $ \user -> do
+4 -6
View File
@@ -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, gap :: Maybe ChatGap}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
@@ -843,10 +843,8 @@ data ChatPagination
| CPInitial Int
deriving (Show)
data ChatLandingSection
= CLSLatest
| CLSUnread
deriving (Show, Eq)
data ChatGap = ChatGap {index :: Maybe Int, size :: Int}
deriving (Show)
data PaginationByTime
= PTLast Int
@@ -1598,7 +1596,7 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CLS") ''ChatLandingSection)
$(JQ.deriveJSON defaultJSON ''ChatGap)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
+166 -55
View File
@@ -138,7 +138,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 (ChatGap (ChatGap, index, size), ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
@@ -947,15 +947,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, Maybe ChatGap)
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 $ (,Nothing) <$> 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
@@ -983,6 +983,27 @@ getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
|]
(userId, contactId, search, count)
getDirectChatItemGapToLatest_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> String -> IO Int
getDirectChatItemGapToLatest_ db User {userId} Contact {contactId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND contact_id = :contactId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":contactId" := contactId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetDirectItem :: DB.Connection -> User -> Contact -> UTCTime -> ChatItemId -> IO (CChatItem 'CTDirect)
safeGetDirectItem db user ct currentTs itemId =
runExceptT (getDirectCIWithReactions db user ct itemId)
@@ -1025,14 +1046,16 @@ getDirectChatItemLast db user@User {userId} contactId = do
(userId, contactId)
getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getDirectChatItem db user contactId afterChatItemId
chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) chatItems stats
gapToLatest <- liftIO $ getDirectChatItemGapToLatest_ db user ct (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (DirectChat ct) chatItems stats, chatGap)
getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId count search afterChatItemCreatedAt =
@@ -1049,14 +1072,14 @@ getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId
|]
(userId, contactId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getDirectChatBefore_ db user ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse chatItems) stats
pure (Chat (DirectChat ct) (reverse chatItems) stats, Nothing)
getDirectChatItemsIdsBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItemId count search beforeChatItemCreatedAt =
@@ -1073,7 +1096,7 @@ getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItem
|]
(userId, contactId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
@@ -1084,18 +1107,34 @@ getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search =
beforeChatItems <- liftIO $ reverse <$> 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
gapToLatest <- liftIO $ getDirectChatItemGapToLatest_ db user ct (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (DirectChat ct) chatItems stats, chatGap)
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect, ChatLandingSection)
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
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 ""
(chat, gap) <- getDirectChatAround_ db user ct firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getDirectChatLast_ db user ct count ""
where
getLatestItems_ :: Chat 'CTDirect -> Int -> ExceptT StoreError IO (Chat 'CTDirect, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getDirectChatItemIdsLast_ db user ct (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getDirectChatMinUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $
@@ -1108,21 +1147,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, Maybe ChatGap)
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 $ (,Nothing) <$> 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
@@ -1149,6 +1182,27 @@ getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
|]
(userId, groupId, search, count)
getGroupChatItemGapToLatest_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> String -> IO Int
getGroupChatItemGapToLatest_ db User {userId} GroupInfo {groupId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND group_id = :groupId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":groupId" := groupId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup)
safeGetGroupItem db user g currentTs itemId =
runExceptT (getGroupCIWithReactions db user g itemId)
@@ -1191,14 +1245,16 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
(userId, groupId, groupMemberId)
getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChatAfter_ db user g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ db user g afterChatItemId count search (chatItemTs afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) chatItems stats
gapToLatest <- liftIO $ getGroupChatItemGapToLatest_ db user g (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (GroupChat g) chatItems stats, chatGap)
getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
@@ -1215,14 +1271,14 @@ getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId c
|]
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChatBefore_ db user g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ db user g beforeChatItemId count search (chatItemTs beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse chatItems) stats
pure (Chat (GroupChat g) (reverse chatItems) stats, Nothing)
getGroupChatItemIdsBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId count search beforeChatItemTs =
@@ -1239,7 +1295,7 @@ getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId
|]
(userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
@@ -1250,17 +1306,23 @@ getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
beforeChatItems <- liftIO $ reverse <$> 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
gapToLatest <- liftIO $ getGroupChatItemGapToLatest_ db user g (last chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (GroupChat g) chatItems stats, chatGap)
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, ChatLandingSection)
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
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 ""
(chat, gap) <- getGroupChatAround_ db user g firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g count ""
where
getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getGroupChatMinUnreadItemId_ =
@@ -1273,16 +1335,26 @@ getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
WHERE user_id = ? AND group_id = ? AND item_status = ?
|]
(userId, groupId, CISRcvNew)
getLatestItems_ :: Chat 'CTGroup -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getGroupChatItemIdsLast_ db user g (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
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, Maybe ChatGap)
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 $ (,Nothing) <$> 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
@@ -1309,6 +1381,27 @@ getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search
|]
(userId, noteFolderId, search, count)
getLocalChatItemGapToLatest_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> String -> IO Int
getLocalChatItemGapToLatest_ db User {userId} NoteFolder {noteFolderId} chatItem search = do
count <-
maybeFirstRow fromOnly $
DB.queryNamed
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = :userId AND note_folder_id = :noteFolderId AND item_text LIKE '%' || :search || '%'
AND (created_at > :itemCreatedAt OR (created_at = :itemCreatedAt AND chat_item_id > :chatItemId))
ORDER BY created_at DESC, chat_item_id DESC
|]
[ ":userId" := userId,
":noteFolderId" := noteFolderId,
":search" := search,
":itemCreatedAt" := chatItemCreatedAt chatItem,
":chatItemId" := cchatItemId chatItem
]
pure $ maybe 0 (\c -> max 0 (c - 1)) count
safeGetLocalItem :: DB.Connection -> User -> NoteFolder -> UTCTime -> ChatItemId -> IO (CChatItem 'CTLocal)
safeGetLocalItem db user NoteFolder {noteFolderId} currentTs itemId =
runExceptT (getLocalChatItem db user noteFolderId itemId)
@@ -1335,14 +1428,16 @@ safeToLocalItem currentTs itemId = \case
file = Nothing
}
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) chatItems stats
gapToLatest <- liftIO $ getLocalChatItemGapToLatest_ db user nf (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (LocalChat nf) chatItems stats, chatGap)
getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatItemId count search afterChatItemCreatedAt =
@@ -1359,14 +1454,14 @@ getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatIt
|]
(userId, noteFolderId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
currentTs <- liftIO getCurrentTime
chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse chatItems) stats
pure (Chat (LocalChat nf) (reverse chatItems) stats, Nothing)
getLocalChatItemIdsBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChatItemId count search beforeChatItemCreatedAt =
@@ -1383,7 +1478,7 @@ getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChat
|]
(userId, noteFolderId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
@@ -1394,17 +1489,23 @@ getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count sear
beforeChatItems <- liftIO $ reverse <$> 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
gapToLatest <- liftIO $ getLocalChatItemGapToLatest_ db user nf (head chatItems) search
let chatGap = if gapToLatest > 0 then Just $ ChatGap {size = gapToLatest, index = Nothing} else Nothing
pure (Chat (LocalChat nf) chatItems stats, chatGap)
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal, ChatLandingSection)
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
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 ""
(chat, gap) <- getLocalChatAround_ db user nf firstUnreadItemId count ""
case gap of
Just ChatGap {size} -> do
if size > snd (divideFetchCountAround_ count)
then getLatestItems_ chat size
else pure (chat, Nothing)
Nothing -> pure (chat, Nothing)
Nothing -> liftIO $ (,Nothing) <$> getLocalChatLast_ db user nf count ""
where
getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
getLocalChatMinUnreadItemId_ =
@@ -1417,6 +1518,16 @@ getLocalChatInitial_ db user@User {userId} nf@NoteFolder {noteFolderId} count =
WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
|]
(userId, noteFolderId, CISRcvNew)
getLatestItems_ :: Chat 'CTLocal -> Int -> ExceptT StoreError IO (Chat 'CTLocal, Maybe ChatGap)
getLatestItems_ c@Chat {chatItems} gapToLatest = do
currentTs <- liftIO getCurrentTime
latestItemIds <- liftIO $ getLocalChatItemIdsLast_ db user nf (min count gapToLatest) ""
latestItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) latestItemIds
let allItems = chatItems <> latestItems
let chat = c {chatItems = allItems}
if gapToLatest > length latestItems
then pure (chat, Just $ ChatGap {size = gapToLatest - length latestItems, index = Just $ length chatItems})
else pure (chat, Nothing)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case
+7 -1
View File
@@ -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 gap -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] <> viewChatGap gap
CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
@@ -555,6 +555,12 @@ viewUsersList us =
viewGroupSubscribed :: GroupInfo -> [StyledString]
viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"]
viewChatGap :: Maybe ChatGap -> [StyledString]
viewChatGap Nothing = []
viewChatGap (Just ChatGap {size})
| size < 1 = []
| otherwise = [sShow size <> " newer message(s) available"]
showSMPServer :: SMPServer -> String
showSMPServer ProtocolServer {host} = B.unpack $ strEncode host
+4
View File
@@ -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,