Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-03-22 14:22:23 +00:00
46 changed files with 1219 additions and 314 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 --constraint 'simplexmq +client_library'
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
+6 -1
View File
@@ -234,6 +234,10 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
@@ -305,12 +309,13 @@ What is already implemented:
15. Manual messaging queue rotations to move conversation to another SMP relay.
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
17. Local files encryption.
18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
We plan to add:
1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
3. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
3. Reproducible clients builds this is a complex problem, but we are aiming to have it in 2025 at least partially.
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers
+4
View File
@@ -66,6 +66,10 @@ class ItemsModel: ObservableObject {
private var navigationTimeoutTask: Task<Void, Never>? = nil
private var loadChatTask: Task<Void, Never>? = nil
var lastItemsLoaded: Bool {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
init() {
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
+179 -10
View File
@@ -60,6 +60,8 @@ func apiLoadMessages(
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
PreloadState.shared.clear()
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@@ -104,19 +106,22 @@ func apiLoadMessages(
}
}
case .around:
let newSplits: [Int64]
var newSplits: [Int64]
if openAroundItemId == nil {
newItems.append(contentsOf: oldItems)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
} else {
newSplits = []
}
// currently, items will always be added on top, which is index 0
newItems.insert(contentsOf: chat.chatItems, at: 0)
let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
//indexToInsertAroundTest()
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
let newReversed: [ChatItem] = newItems.reversed()
let orderedSplits = newSplits
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = [chat.chatItems.last!.id] + newSplits
chatState.splits = orderedSplits
chatState.unreadAfterItemId = chat.chatItems.last!.id
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
@@ -130,14 +135,16 @@ func apiLoadMessages(
// no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
PreloadState.shared.clear()
}
case .last:
newItems.append(contentsOf: oldItems)
removeDuplicates(&newItems, chat)
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
newItems.append(contentsOf: chat.chatItems)
let items = newItems
await MainActor.run {
ItemsModel.shared.reversedChatItems = items.reversed()
chatState.splits = newSplits
chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0
}
@@ -234,10 +241,14 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
// Currently, it should always load from split range
let loadingFromSplitRange = indexInSplitRanges != nil
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
Array(splits[indexInSplitRanges + 1 ..< splits.count])
let topSplits: [Int64]
var splitsToMerge: [Int64]
if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count])
topSplits = Array(splits[0 ..< indexInSplitRanges + 1])
} else {
[]
splitsToMerge = []
topSplits = []
}
newItems.removeAll(where: { new in
let duplicate = newIds.contains(new.id)
@@ -257,8 +268,8 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
})
var newSplits: [Int64] = []
if firstItemIdBelowAllSplits != nil {
// no splits anymore, all were merged with bottom items
newSplits = []
// no splits below anymore, all were merged with bottom items
newSplits = topSplits
} else {
if !splitsToRemove.isEmpty {
var new = splits
@@ -320,6 +331,28 @@ private func removeDuplicatesAndUpperSplits(
return newSplits
}
private func removeDuplicatesAndUnusedSplits(
_ newItems: inout [ChatItem],
_ chat: Chat,
_ splits: [Int64]
) async -> [Int64] {
if splits.isEmpty {
removeDuplicates(&newItems, chat)
return splits
}
var newSplits = splits
let (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll(where: {
let duplicate = newIds.contains($0.id)
if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) {
newSplits.remove(at: firstIndex)
}
return duplicate
})
return newSplits
}
// ids, number of unread items
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
var unreadInLoaded = 0
@@ -340,3 +373,139 @@ private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
let (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains($0.id) }
}
private typealias SameTimeItem = (index: Int, item: ChatItem)
// return (item index, split index)
private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set<Int64>) -> (Int, Int) {
guard to.count > 0, let lastNew = lastNew else { return (0, 0) }
// group sorting: item_ts, item_id
// everything else: created_at, item_id
let compareByTimeTs = chatType == .group
// in case several items have the same time as another item in the `to` array
var sameTime: [SameTimeItem] = []
// trying to find new split index for item looks difficult but allows to not use one more loop.
// The idea is to memorize how many splits were till any index (map number of splits until index)
// and use resulting itemIndex to decide new split index position.
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
var splitsTillIndex: [Int] = []
var splitsPerPrevIndex = 0
for i in 0 ..< to.count {
let item = to[i]
splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex
splitsTillIndex.append(splitsPerPrevIndex)
let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt)
if itemIsNewer || i + 1 == to.count {
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
sameTime.append((i, item))
}
// time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them
let itemIndex: Int
if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) {
itemIndex = first.index
} else if sameTime.count == 1 {
itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1
} else {
itemIndex = itemIsNewer ? i : i + 1
}
let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)]
let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)]
return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex)
}
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
sameTime.append(SameTimeItem(index: i, item: item))
} else {
sameTime = []
}
}
// shouldn't be here
return (to.count, splits.count)
}
private func indexToInsertAroundTest() {
func assert(_ one: (Int, Int), _ two: (Int, Int)) {
if one != two {
logger.debug("\(String(describing: one)) != \(String(describing: two))")
fatalError()
}
}
let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")]
let items1 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1))
let items2 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1))
let items3 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1))
let items4 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0))
let items5 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1))
let items6 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0))
let items7 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0))
let items8 = [
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0))
let items9 = [
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0))
let items10 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0))
let items11: [ChatItem] = []
assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0))
}
@@ -9,25 +9,23 @@
import SwiftUI
import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) {
if ItemsModel.shared.chatState.totalAfter == 0 {
return
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
await MainActor.run {
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
}
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
Task {
try? await Task.sleep(nanoseconds: 500_000000)
if ChatModel.shared.chatId != chat.chatInfo.id {
await MainActor.run {
loadingMoreItems.wrappedValue = false
}
return
}
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
try? await Task.sleep(nanoseconds: 500_000000)
if ChatModel.shared.chatId != chat.chatInfo.id {
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
return
}
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
}
@@ -36,6 +34,12 @@ class PreloadState {
var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0
var preloading: Bool = false
func clear() {
prevFirstVisible = Int64.min
prevItemsCount = 0
preloading = false
}
}
func preloadIfNeeded(
@@ -43,26 +47,41 @@ func preloadIfNeeded(
_ ignoreLoadingRequests: Binding<Int64?>,
_ listState: EndlessScrollView<MergedItem>.ListState,
_ mergedItems: BoxedValue<MergedItems>,
loadItems: @escaping (Bool, ChatPagination) async -> Bool
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) {
let state = PreloadState.shared
guard !listState.isScrolling && !listState.isAnimatedScrolling,
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
!state.preloading,
listState.totalItemsCount > 0
else {
return
}
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
state.preloading = true
let allowLoadMore = allowLoadMoreItems.wrappedValue
Task {
defer {
state.preloading = false
if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
state.preloading = true
let allowLoadMore = allowLoadMoreItems.wrappedValue
Task {
defer { state.preloading = false }
var triedToLoad = true
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
if triedToLoad {
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
}
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
// split will be merged with last items and position of scroll will change unexpectedly.
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
await loadLastItems()
}
}
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
await loadItems(false, pagination)
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
await loadLastItems()
}
}
}
@@ -105,6 +124,7 @@ async {
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
}
return triedToLoad
}
+31 -22
View File
@@ -91,7 +91,11 @@ struct ChatView: View {
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
}
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel)
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
scrollView.updateItems(mergedItems.boxedValue.items)
}
)
}
connectingText()
if selectedChatItems == nil {
@@ -262,7 +266,6 @@ struct ChatView: View {
// this may already being loading because of changed chat id (see .onChange(of: chat.id)
if !loadingBottomItems {
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true
@@ -584,7 +587,6 @@ struct ChatView: View {
scrollView.updateItems(mergedItems.boxedValue.items)
}
.onChange(of: chat.id) { _ in
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true
@@ -629,7 +631,6 @@ struct ChatView: View {
if let unreadIndex {
scrollView.scrollToItem(unreadIndex)
}
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true
}
@@ -647,10 +648,8 @@ struct ChatView: View {
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
// scroll to the top unread item
scrollView.scrollToItem(index)
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
} else {
scrollView.scrollToBottom()
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
}
}
}
@@ -731,6 +730,7 @@ struct ChatView: View {
let theme: AppTheme
let scrollView: EndlessScrollView<MergedItem>
let chat: Chat
@Binding var loadingMoreItems: Bool
@Binding var loadingTopItems: Bool
@Binding var requestedTopScroll: Bool
@Binding var loadingBottomItems: Bool
@@ -738,6 +738,7 @@ struct ChatView: View {
@Binding var animatedScrollingInProgress: Bool
let listState: EndlessScrollView<MergedItem>.ListState
@ObservedObject var model: FloatingButtonModel
let reloadItems: () -> Void
var body: some View {
ZStack(alignment: .top) {
@@ -795,7 +796,7 @@ struct ChatView: View {
}
}
.onTapGesture {
if loadingBottomItems {
if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
requestedTopScroll = false
requestedBottomScroll = true
} else {
@@ -815,7 +816,7 @@ struct ChatView: View {
}
}
.onChange(of: loadingBottomItems) { loading in
if !loading && requestedBottomScroll {
if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
requestedBottomScroll = false
scrollToBottom()
}
@@ -824,15 +825,25 @@ struct ChatView: View {
}
private func scrollToTopUnread() {
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
animatedScrollingInProgress = true
// scroll to the top unread item
Task {
Task {
if !ItemsModel.shared.chatState.splits.isEmpty {
await MainActor.run { loadingMoreItems = true }
await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
await MainActor.run { reloadItems() }
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
await scrollView.scrollToItemAnimated(index)
await MainActor.run { animatedScrollingInProgress = false }
}
await MainActor.run { loadingMoreItems = false }
} else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
// scroll to the top unread item
await scrollView.scrollToItemAnimated(index)
await MainActor.run { animatedScrollingInProgress = false }
} else {
logger.debug("No more unread items, total: \(listState.items.count)")
}
} else {
logger.debug("No more unread items, total: \(listState.items.count)")
}
}
@@ -1147,6 +1158,11 @@ struct ChatView: View {
} else {
await loadChatItems(chat, pagination)
}
},
loadLastItems: {
if !loadingMoreItems {
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
}
}
)
}
@@ -1247,18 +1263,11 @@ struct ChatView: View {
nil
}
let showAvatar = shouldShowAvatar(item, listItem.nextItem)
let itemSeparation: ItemSeparation
let single = switch merged {
case .single: true
default: false
}
if single || revealed {
let prev = listItem.prevItem
itemSeparation = getItemSeparation(item, prev)
let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem
} else {
itemSeparation = getItemSeparation(item, nil)
}
let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
return VStack(spacing: 0) {
if let last {
DateSeparator(date: last.meta.itemTs).padding(8)
@@ -171,6 +171,9 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
visibleItems.last?.index ?? 0
}
/// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
var itemsCanCoverScreen: Bool = false
/// Whether there is a non-animated scroll to item in progress or not
var isScrolling: Bool = false
/// Whether there is an animated scroll to item in progress or not
@@ -284,7 +287,8 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
if !Thread.isMainThread {
fatalError("Use main thread to update items")
logger.error("Use main thread to update items")
return
}
if bounds.height == 0 {
self.listState.items = items
@@ -302,6 +306,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
if items.isEmpty {
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
listState.visibleItems = []
listState.itemsCanCoverScreen = false
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = 0
listState.firstVisibleItemOffset = -insetTop
@@ -322,6 +327,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
var oldVisible = listState.visibleItems
var newVisible: [VisibleItem] = []
var visibleItemsHeight: CGFloat = 0
let offsetsDiff = contentOffsetY - prevProcessedOffset
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
@@ -339,7 +345,11 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
if let visibleIndex {
let v = oldVisible.remove(at: visibleIndex)
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
let wasHeight = v.view.bounds.height
updateCell(v.view, i, items)
if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i {
v.view.frame.origin.y -= v.view.bounds.height - wasHeight
}
}
visible = v
} else {
@@ -389,6 +399,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
addSubview(vis.view)
}
newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
nextOffsetY = vis.view.frame.origin.y
} else {
let vis: VisibleItem
@@ -406,6 +417,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
addSubview(vis.view)
}
newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
}
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
break
@@ -435,6 +447,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
}
offset += vis.view.frame.height
newVisible.insert(vis, at: 0)
visibleItemsHeight += vis.view.frame.height
if offset >= contentOffsetY + bounds.height {
break
}
@@ -450,11 +463,15 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
prevProcessedOffset = contentOffsetY
listState.visibleItems = newVisible
listState.items = items
// bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
// For visible items to preserve offset after adding more items having such height is enough
listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
// updating the items with the last step in order to call listener with fully updated state
listState.items = items
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
+20 -18
View File
@@ -179,8 +179,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -541,8 +541,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -698,8 +698,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -784,8 +784,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-3RDt4h0Fq4hJV00CU7V8H.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.8-CNSVk2Y5c4gAUnxeodOxfm.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1973,7 +1973,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1999,6 +1999,7 @@
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.3;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -2022,7 +2023,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2048,6 +2049,7 @@
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.3;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -2063,7 +2065,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2083,7 +2085,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2108,7 +2110,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2145,7 +2147,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2182,7 +2184,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2233,7 +2235,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2284,7 +2286,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2318,7 +2320,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 268;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+29 -1
View File
@@ -183,7 +183,9 @@ public func chatResponse(_ s: String) -> ChatResponse {
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
do {
let r = try jsonDecoder.decode(APIResponse.self, from: d)
let r = try callWithLargeStack {
try jsonDecoder.decode(APIResponse.self, from: d)
}
return r.resp
} catch {
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
@@ -231,6 +233,32 @@ public func chatResponse(_ s: String) -> ChatResponse {
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
private let largeStackSize: Int = 2 * 1024 * 1024
private func callWithLargeStack<T>(_ f: @escaping () throws -> T) throws -> T {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<T, Error>?
let thread = Thread {
do {
result = .success(try f())
} catch {
result = .failure(error)
}
semaphore.signal()
}
thread.stackSize = largeStackSize
thread.qualityOfService = Thread.current.qualityOfService
thread.start()
semaphore.wait()
switch result! {
case let .success(r): return r
case let .failure(e): throw e
}
}
private func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
if let user_ = jDict["user_"] {
try? decodeObject(user_ as Any)
@@ -79,7 +79,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
if (rerunMigration) {
chatModel.dbMigrationInProgress.value = true
migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
migrated = if (databaseBackend == "postgres") {
chatMigrateInit("simplex_v1", "postgresql://simplex@/simplex_v1", confirm.value)
} else {
chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
}
res = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
@@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.Instant
import kotlin.math.min
const val TRIM_KEEP_COUNT = 200
@@ -122,18 +123,20 @@ suspend fun processLoadedChat(
}
}
is ChatPagination.Around -> {
val newSplits = if (openAroundItemId == null) {
val newSplits: ArrayList<Long> = if (openAroundItemId == null) {
newItems.addAll(oldItems)
removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
ArrayList(removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed))
} else {
emptyList()
arrayListOf()
}
// currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems)
val (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.lastOrNull(), to = newItems, newSplits.toSet())
//indexToInsertAroundTest()
newItems.addAll(itemIndex, chat.chatItems)
newSplits.add(splitIndex, chat.chatItems.last().id)
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits
splits.value = newSplits
unreadAfterItemId.value = chat.chatItems.last().id
totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount
@@ -151,10 +154,12 @@ suspend fun processLoadedChat(
}
is ChatPagination.Last -> {
newItems.addAll(oldItems)
val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value)
removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
chatState.splits.value = newSplits
unreadAfterNewestLoaded.value = 0
}
}
@@ -240,7 +245,15 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
val indexInSplitRanges = splits.value.indexOf(paginationChatItemId)
// Currently, it should always load from split range
val loadingFromSplitRange = indexInSplitRanges != -1
val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList()
val topSplits: List<Long>
val splitsToMerge: ArrayList<Long>
if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) {
splitsToMerge = ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size))
topSplits = splits.value.take(indexInSplitRanges + 1)
} else {
splitsToMerge = ArrayList()
topSplits = emptyList()
}
newItems.removeAll {
val duplicate = newIds.contains(it.id)
if (loadingFromSplitRange && duplicate) {
@@ -259,8 +272,8 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
}
var newSplits: List<Long> = emptyList()
if (firstItemIdBelowAllSplits != null) {
// no splits anymore, all were merged with bottom items
newSplits = emptyList()
// no splits below anymore, all were merged with bottom items
newSplits = topSplits
} else {
if (splitsToRemove.isNotEmpty()) {
val new = ArrayList(splits.value)
@@ -323,6 +336,31 @@ private fun removeDuplicatesAndUpperSplits(
return newSplits
}
private fun removeDuplicatesAndUnusedSplits(
newItems: SnapshotStateList<ChatItem>,
chat: Chat,
splits: List<Long>
): List<Long> {
if (splits.isEmpty()) {
removeDuplicates(newItems, chat)
return splits
}
val newSplits = splits.toMutableList()
val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll {
val duplicate = newIds.contains(it.id)
if (duplicate) {
val firstIndex = newSplits.indexOf(it.id)
if (firstIndex != -1) {
newSplits.removeAt(firstIndex)
}
}
duplicate
}
return newSplits
}
// ids, number of unread items
private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> {
var unreadInLoaded = 0
@@ -343,3 +381,141 @@ private fun removeDuplicates(newItems: SnapshotStateList<ChatItem>, chat: Chat)
val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains(it.id) }
}
private data class SameTimeItem(val index: Int, val item: ChatItem)
// return (item index, split index)
private fun indexToInsertAround(chatType: ChatType, lastNew: ChatItem?, to: List<ChatItem>, splits: Set<Long>): Pair<Int, Int> {
if (to.size <= 0 || lastNew == null) {
return 0 to 0
}
// group sorting: item_ts, item_id
// everything else: created_at, item_id
val compareByTimeTs = chatType == ChatType.Group
// in case several items have the same time as another item in the `to` array
var sameTime: ArrayList<SameTimeItem> = arrayListOf()
// trying to find new split index for item looks difficult but allows to not use one more loop.
// The idea is to memorize how many splits were till any index (map number of splits until index)
// and use resulting itemIndex to decide new split index position.
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
val splitsTillIndex: ArrayList<Int> = arrayListOf()
var splitsPerPrevIndex = 0
for (i in to.indices) {
val item = to[i]
splitsPerPrevIndex = if (splits.contains(item.id)) splitsPerPrevIndex + 1 else splitsPerPrevIndex
splitsTillIndex.add(splitsPerPrevIndex)
val itemIsNewer = (if (compareByTimeTs) item.meta.itemTs > lastNew.meta.itemTs else item.meta.createdAt > lastNew.meta.createdAt)
if (itemIsNewer || i + 1 == to.size) {
val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt
if (same) {
sameTime.add(SameTimeItem(i, item))
}
// time to stop the loop. Item is newer, or it's the last item in `to` array, taking previous items and checking position inside them
val itemIndex: Int
val first = if (sameTime.size > 1) sameTime.sortedWith { prev, next -> prev.item.meta.itemId.compareTo(next.item.id) }.firstOrNull { same -> same.item.id > lastNew.id } else null
if (sameTime.size > 1 && first != null) {
itemIndex = first.index
} else if (sameTime.size == 1) {
itemIndex = if (sameTime[0].item.id > lastNew.id) sameTime[0].index else sameTime[0].index + 1
} else {
itemIndex = if (itemIsNewer) i else i + 1
}
val splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.size - 1)]
val prevItemSplitIndex = if (itemIndex == 0) 0 else splitsTillIndex[min(itemIndex - 1, splitsTillIndex.size - 1)]
return Pair(itemIndex, if (splitIndex == prevItemSplitIndex) splitIndex else prevItemSplitIndex)
}
val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt
if (same) {
sameTime.add(SameTimeItem(index = i, item = item))
} else {
sameTime = arrayListOf()
}
}
// shouldn't be here
return Pair(to.size, splits.size)
}
private fun indexToInsertAroundTest() {
fun assert(one: Pair<Int, Int>, two: Pair<Int, Int>) {
if (one != two) {
throw Exception("$one != $two")
}
}
val itemsToInsert = listOf(ChatItem.getSampleData(3, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 3), ""))
val items1 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 1), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 2), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items1, setOf(1)), Pair(3, 1))
val items2 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(1), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items2, setOf(2)), Pair(3, 1))
val items3 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items3, setOf(1)), Pair(3, 1))
val items4 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items4, setOf(4)), Pair(1, 0))
val items5 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items5, setOf(2)), Pair(2, 1))
val items6 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items6, setOf(5)), Pair(0, 0))
val items7 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, null, to = items7, setOf(6)), Pair(0, 0))
val items8 = listOf(
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items8, setOf(2)), Pair(0, 0))
val items9 = listOf(
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items9, setOf(5)), Pair(1, 0))
val items10 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items10, setOf(4)), Pair(0, 0))
val items11: List<ChatItem> = listOf()
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items11, emptySet()), Pair(0, 0))
}
@@ -1185,6 +1185,22 @@ fun BoxScope.ChatItemsList(
developerTools: Boolean,
showViaProxy: Boolean
) {
val loadingTopItems = remember { mutableStateOf(false) }
val loadingBottomItems = remember { mutableStateOf(false) }
// just for changing local var here based on request
val loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit = { chatId, pagination, visibleItemIndexesNonReversed ->
val loadingSide = when (pagination) {
is ChatPagination.Before -> loadingTopItems
is ChatPagination.Last -> loadingBottomItems
is ChatPagination.After, is ChatPagination.Around, is ChatPagination.Initial -> null
}
loadingSide?.value = true
try {
loadMessages(chatId, pagination, visibleItemIndexesNonReversed)
} finally {
loadingSide?.value = false
}
}
val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } }
val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } }
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
@@ -1227,7 +1243,7 @@ fun BoxScope.ChatItemsList(
if (reportsState != null) {
reportsListState = null
reportsState
} else if (index <= 0) {
} else if (index <= 0 || !searchValueIsEmpty.value) {
LazyListState(0, 0)
} else {
LazyListState(index + 1, -maxHeightForList.value)
@@ -1242,19 +1258,19 @@ fun BoxScope.ChatItemsList(
if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT)
ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect)
}
if (!loadingMoreItems.value) {
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
if (loadingMoreItems.value) return@PreloadItems false
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false
loadingMoreItems.value = true
withContext(NonCancellable) {
try {
loadingMoreItems.value = true
loadMessages(chatId, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
} finally {
loadingMoreItems.value = false
}
true
}
true
}
val remoteHostIdUpdated = rememberUpdatedState(remoteHostId)
@@ -1275,7 +1291,6 @@ fun BoxScope.ChatItemsList(
scrollToItemId.value = null }
}
}
LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo)
SmallScrollOnNewMessage(listState, reversedChatItems)
val finishedInitialComposition = remember { mutableStateOf(false) }
NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed)
@@ -1583,7 +1598,25 @@ fun BoxScope.ChatItemsList(
}
}
}
FloatingButtons(topPaddingToContent, topPaddingToContentPx, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState)
FloatingButtons(
reversedChatItems,
chatInfoUpdated,
topPaddingToContent,
topPaddingToContentPx,
contentTag,
loadingMoreItems,
loadingTopItems,
loadingBottomItems,
animatedScrollingInProgress,
mergedItems,
unreadCount,
maxHeight,
composeViewHeight,
searchValue,
markChatRead,
listState,
loadMessages
)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState)
LaunchedEffect(Unit) {
@@ -1603,21 +1636,20 @@ fun BoxScope.ChatItemsList(
}
}
@Composable
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, resetListState: State<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
val contentTag = LocalContentTag.current
LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) {
try {
loadingMoreItems.value = true
if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect
delay(500)
withContext(Dispatchers.Default) {
apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
} finally {
loadingMoreItems.value = false
}
}
private suspend fun loadLastItems(chatId: State<ChatId>, contentTag: MsgContentTag?, listState: State<LazyListState>, loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>) {
val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull()
val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size
if (!itemsCanCoverScreen) return
if (lastItemsLoaded(contentTag)) return
delay(500)
loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean {
val chatState = chatModel.chatStateForContent(contentTag)
return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id
}
// TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message
@@ -1680,9 +1712,14 @@ private fun NotifyChatListOnFinishingComposition(
@Composable
fun BoxScope.FloatingButtons(
reversedChatItems: State<List<ChatItem>>,
chatInfo: State<ChatInfo>,
topPaddingToContent: Dp,
topPaddingToContentPx: State<Int>,
contentTag: MsgContentTag?,
loadingMoreItems: MutableState<Boolean>,
loadingTopItems: MutableState<Boolean>,
loadingBottomItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
mergedItems: State<MergedItems>,
unreadCount: State<Int>,
@@ -1690,9 +1727,44 @@ fun BoxScope.FloatingButtons(
composeViewHeight: State<Dp>,
searchValue: State<String>,
markChatRead: () -> Unit,
listState: State<LazyListState>
listState: State<LazyListState>,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit
) {
val scope = rememberCoroutineScope()
fun scrollToBottom() {
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) }
}
}
fun scrollToTopUnread() {
scope.launch {
tryBlockAndSetLoadingMore(loadingMoreItems) {
if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) {
val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
val oldSize = reversedChatItems.value.size
loadMessages(chatInfo.value.id, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
var repeatsLeft = 100
while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) {
delay(10)
repeatsLeft--
}
if (oldSize == reversedChatItems.value.size) {
return@tryBlockAndSetLoadingMore
}
}
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) {
// scroll to the top unread item
animatedScrollingInProgress.value = true
listState.value.animateScrollToItem(index + 1, -maxHeight.value)
}
}
}
}
val bottomUnreadCount = remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
@@ -1718,43 +1790,73 @@ fun BoxScope.FloatingButtons(
allowToShowBottomWithArrow.value = shouldShow
shouldShow && allow
} }
val requestedTopScroll = remember { mutableStateOf(false) }
val requestedBottomScroll = remember { mutableStateOf(false) }
BottomEndFloatingButton(
bottomUnreadCount,
showBottomButtonWithCounter,
showBottomButtonWithArrow,
requestedBottomScroll,
animatedScrollingInProgress,
composeViewHeight,
onClick = {
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) }
if (loadingBottomItems.value || !lastItemsLoaded(contentTag)) {
requestedTopScroll.value = false
requestedBottomScroll.value = true
} else {
scrollToBottom()
}
}
)
LaunchedEffect(Unit) {
launch {
snapshotFlow { loadingTopItems.value }
.drop(1)
.collect { top ->
if (!top && requestedTopScroll.value) {
requestedTopScroll.value = false
scrollToTopUnread()
}
}
}
launch {
snapshotFlow { loadingBottomItems.value }
.drop(1)
.collect { bottom ->
if (!bottom && requestedBottomScroll.value) {
requestedBottomScroll.value = false
scrollToBottom()
}
}
}
}
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
val fabSize = 56.dp
val topUnreadCount = remember { derivedStateOf { if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } }
val topUnreadCount = remember { derivedStateOf {
if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 }
}
val showDropDown = remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd),
topUnreadCount,
requestedTopScroll,
animatedScrollingInProgress,
onClick = {
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) {
// scroll to the top unread item
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) }
}
if (loadingTopItems.value) {
requestedBottomScroll.value = false
requestedTopScroll.value = true
} else {
scrollToTopUnread()
}
},
onLongClick = { showDropDown.value = true }
)
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).align(Alignment.TopEnd)) {
val density = LocalDensity.current
val width = remember { mutableStateOf(250.dp) }
DefaultDropdownMenu(
@@ -1777,6 +1879,8 @@ fun BoxScope.FloatingButtons(
fun PreloadItems(
chatId: String,
ignoreLoadingRequests: MutableSet<Long>,
loadingMoreItems: State<Boolean>,
resetListState: State<Boolean>,
contentTag: MsgContentTag?,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
@@ -1788,13 +1892,32 @@ fun PreloadItems(
val chatId = rememberUpdatedState(chatId)
val loadItems = rememberUpdatedState(loadItems)
val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests)
PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)
PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems)
LaunchedEffect(Unit) {
snapshotFlow { chatId.value }
.distinctUntilChanged()
.filterNotNull()
.collect {
allowLoad.value = false
delay(500)
allowLoad.value = true
}
}
if (allowLoad.value && !loadingMoreItems.value) {
LaunchedEffect(chatId.value, resetListState.value) {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { firstVisibleIndex ->
if (!preloadItemsBefore(firstVisibleIndex, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)) {
preloadItemsAfter(firstVisibleIndex, chatId, contentTag, mergedItems, remaining, loadItems)
}
loadLastItems(chatId, contentTag, listState, loadItems)
}
}
}
}
@Composable
private fun PreloadItemsBefore(
allowLoad: State<Boolean>,
private suspend fun preloadItemsBefore(
firstVisibleIndex: Int,
chatId: State<String>,
ignoreLoadingRequests: State<MutableSet<Long>>,
contentTag: MsgContentTag?,
@@ -1802,83 +1925,47 @@ private fun PreloadItemsBefore(
listState: State<LazyListState>,
remaining: Int,
loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>,
) {
KeyChangeEffect(allowLoad.value, chatId.value) {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
val items = reversedChatItemsStatic(contentTag)
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex
}
if (allowLoad.value && lastIndexToLoadFrom != null) {
items.getOrNull(lastIndexToLoadFrom)?.id
} else {
null
}
}
.filterNotNull()
.filter { !ignoreLoadingRequests.value.contains(it) }
.collect { loadFromItemId ->
withBGApi {
val items = reversedChatItemsStatic(contentTag)
val sizeWas = items.size
val oldestItemIdWas = items.lastOrNull()?.id
val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT))
val itemsUpdated = reversedChatItemsStatic(contentTag)
if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId)
}
}
}
): Boolean {
val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
val items = reversedChatItemsStatic(contentTag)
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex
}
if (lastIndexToLoadFrom != null) {
val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false
if (!ignoreLoadingRequests.value.contains(loadFromItemId)) {
val items = reversedChatItemsStatic(contentTag)
val sizeWas = items.size
val oldestItemIdWas = items.lastOrNull()?.id
val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT))
val itemsUpdated = reversedChatItemsStatic(contentTag)
if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId)
return false
}
return triedToLoad
}
}
return false
}
@Composable
private fun PreloadItemsAfter(
allowLoad: MutableState<Boolean>,
private suspend fun preloadItemsAfter(
firstVisibleIndex: Int,
chatId: State<String>,
contentTag: MsgContentTag?,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int,
loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>,
) {
LaunchedEffect(Unit) {
snapshotFlow { chatId.value }
.distinctUntilChanged()
.filterNotNull()
.collect {
allowLoad.value = listState.value.layoutInfo.totalItemsCount == listState.value.layoutInfo.visibleItemsInfo.size
delay(500)
allowLoad.value = true
}
}
LaunchedEffect(chatId.value) {
launch {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val items = reversedChatItemsStatic(contentTag)
val splits = mergedItems.value.splits
val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) }
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
items.getOrNull(split.indexRangeInReversed.first)?.id
} else {
null
}
}
.filterNotNull()
.collect { loadFromItemId ->
withBGApi {
loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT))
}
}
}
val items = reversedChatItemsStatic(contentTag)
val splits = mergedItems.value.splits
val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) }
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
val loadFromItemId = items.getOrNull(split.indexRangeInReversed.first)?.id ?: return
loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT))
}
}
@@ -1893,6 +1980,7 @@ fun MemberImage(member: GroupMember) {
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: State<Int>,
requestedTopScroll: State<Boolean>,
animatedScrollingInProgress: State<Boolean>,
onClick: () -> Unit,
onLongClick: () -> Unit
@@ -1906,11 +1994,15 @@ private fun TopEndFloatingButton(
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
if (requestedTopScroll.value) {
LoadingProgressIndicator()
} else {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
}
}
@@ -2115,7 +2207,7 @@ private fun DateSeparator(date: Instant) {
@Composable
private fun MarkItemsReadAfterDelay(
itemKey: String,
itemKey: ChatViewItemKey,
itemIds: List<Long>,
finishedInitialComposition: State<Boolean>,
chatId: ChatId,
@@ -2153,18 +2245,20 @@ private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List<ChatItem>
private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State<Int>, mergedItems: State<MergedItems>, listState: State<LazyListState>): ListItem? {
val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value
return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffset
}?.index ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest()
}?.key as? ChatViewItemKey
return mergedItems.value.items.getOrNull((mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest()
}
private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State<Int>, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State<MergedItems>, listState: State<LazyListState>): ChatItem? {
val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier
val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0
}?.key as? ChatViewItemKey
return mergedItems.value.items.getOrNull(
(listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0
}
?.index
(mergedItems.value.indexInParentItems[visibleKey?.first]
?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index)
?: -1)?.newest()?.item
}
@@ -2276,39 +2370,50 @@ private fun BoxScope.BottomEndFloatingButton(
unreadCount: State<Int>,
showButtonWithCounter: State<Boolean>,
showButtonWithArrow: State<Boolean>,
requestedBottomScroll: State<Boolean>,
animatedScrollingInProgress: State<Boolean>,
composeViewHeight: State<Dp>,
onClick: () -> Unit
) = when {
showButtonWithCounter.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
) {
when {
showButtonWithCounter.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
if (requestedBottomScroll.value) {
LoadingProgressIndicator()
} else {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
}
}
showButtonWithArrow.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
showButtonWithArrow.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
if (requestedBottomScroll.value) {
LoadingProgressIndicator()
} else {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
}
else -> {}
}
else -> {}
}
@Composable
@@ -2334,6 +2439,20 @@ fun SelectedListItem(
)
}
@Composable
private fun LoadingProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.dp
)
}
}
private fun selectUnselectChatItem(
select: Boolean,
ci: ChatItem,
@@ -2652,7 +2771,9 @@ fun providerForGallery(
}
}
private fun keyForItem(item: ChatItem): String = (item.id to item.meta.createdAt.toEpochMilliseconds()).toString()
typealias ChatViewItemKey = Pair<Long, Long>
private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds())
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis
@@ -379,10 +379,11 @@ fun ModalData.GroupChatInfoLayout(
} else {
PaddingValues(
top = topPaddingToContent(false),
bottom = navBarPadding +
imePadding +
selectedItemsBarHeight +
(if (navBarPadding > 0.dp && imePadding > 0.dp) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)
bottom = if (imePadding > 0.dp) {
imePadding + selectedItemsBarHeight
} else {
navBarPadding + selectedItemsBarHeight
}
)
}
) {
@@ -66,7 +66,11 @@ fun FramedItemView(
@Composable
fun ciQuotedMsgView(qi: CIQuote) {
Box(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
Modifier
// this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it.
// Top level layout wants `IntrinsicWidth.Max` and very long layout makes the crash in this case
.widthIn(max = 50000.dp)
.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
val sender = qi.sender(membership())
@@ -105,13 +105,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
)
}
is ChatInfo.Local -> {
val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {})
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction)
}
},
click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
click = defaultClickAction,
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
NoteFolderMenuItems(chat, showMenu, showMarkRead)
@@ -244,7 +245,7 @@ suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?
withChats(contentTag) {
chatItems.clearAndNotify()
}
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search)
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
@@ -0,0 +1,37 @@
module Directory.Captcha (getCaptchaStr, matchCaptchaStr) where
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import System.Random (randomRIO)
getCaptchaStr :: Int -> String -> IO String
getCaptchaStr 0 s = pure s
getCaptchaStr n s = do
i <- randomRIO (0, length captchaChars - 1)
let c = captchaChars !! i
getCaptchaStr (n - 1) (c : s)
where
captchaChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
matchCaptchaStr :: T.Text -> T.Text -> Bool
matchCaptchaStr captcha guess = T.length captcha == T.length guess && matchChars (T.zip captcha guess)
where
matchChars [] = True
matchChars ((c, g) : cs) = matchChar c == matchChar g && matchChars cs
matchChar c = fromMaybe c $ M.lookup c captchaMatches
captchaMatches =
M.fromList
[ ('0', 'O'),
('1', 'I'),
('c', 'C'),
('l', 'I'),
('o', 'O'),
('p', 'P'),
('s', 'S'),
('u', 'U'),
('v', 'V'),
('w', 'W'),
('x', 'X'),
('z', 'Z')
]
@@ -39,6 +39,7 @@ import qualified Data.Text.IO as T
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Directory.BlockedWords
import Directory.Captcha
import Directory.Events
import Directory.Options
import Directory.Search
@@ -67,7 +68,6 @@ import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>))
import System.Directory (getAppUserDataDirectory)
import System.Process (readProcess)
import System.Random (randomRIO)
data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError
@@ -455,12 +455,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
atomically $ TM.insert gmId captcha $ pendingCaptchas env
sendCaptcha mc
where
getCaptchaStr 0 s = pure s
getCaptchaStr n s = do
i <- randomRIO (0, length chars - 1)
let c = chars !! i
getCaptchaStr (n - 1) (c : s)
chars = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrsty"
getCaptcha s = case captchaGenerator opts of
Nothing -> pure textMsg
Just script -> content <$> readProcess script [s] ""
@@ -491,7 +485,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case
Just PendingCaptcha {captchaText, sentAt, attempts}
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1
| captchaText == msgText -> do
| matchCaptchaStr captchaText msgText -> do
sendComposedMessages_ cc (SRGroup groupId $ Just $ groupMemberId' m) [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
approvePendingMember a g m
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
@@ -2,31 +2,157 @@
layout: layouts/article.html
title: "SimpleX Chat v6.3: new user experience and safety in public groups"
date: 2025-03-08
# previewBody: blog_previews/20241210.html
# image: images/20241210-operators-1.png
draft: true
previewBody: blog_previews/20250308.html
image: images/20250308-captcha.png
imageBottom: true
permalink: "/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"
---
# SimpleX Chat v6.3: new user experience and safety in public groups
**Will be published:** Mar 8, 2025
**Published:** Mar 8, 2025
This is a permalink for release announcement.
**What's new in v6.3**:
- [preventing spam and abuse in public groups](#preventing-spam-and-abuse-in-public-groups).
- [group improvements](#group-improvements): [mention other members](#mention-other-members-and-get-notified-when-mentioned), [improved performance](#better-group-performance).
- [better chat navigation](#better-chat-navigation): [organize chats into lists](#organize-chats-into-lists) and [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages).
- [privacy and security improvements](#privacy-and-security-improvements): [chat retention period](#set-message-retention-period-in-chats) and [private media file names](#private-media-file-names).
What's new in v6.3:
Also, we added Catalan interface language to Android and desktop apps, thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat).
- improved groups:
- [mention other members](#mention-members).
- [private reports](#private-reports) to group admins.
- [preventing spam and abuse](#preventing-spam-and-abuse).
- [better performance](#better-privacy-and-security).
- [chat lists](#chat-lists) to keep track of what's important.
- [jump to found and forwarded messages](#jump-to-found-and-forwarded-messages)
- [privacy and security improvements](#privacy-and-security-improvements).
The last but not the least - [server builds are now reproducible](#reproducible-server-builds).
## What's new in v6.3
## Preventing spam and abuse in public groups
[We wrote before](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md): as the network grows, it becomes more attractive to attackers. This release adds several features that reduce the possibility of attacks and abuse.
### Spam in groups that are listed in our group directory
There is no built-in group discovery in SimpleX Chat apps. Instead, we offer an experimental chat bot that allows to submit and to discover public groups. Not so long ago, spammers started sending messages via bots attempting to disrupt these groups.
We released several changes to the groups directory to protect from spam attacks.
**Optional captcha verification**
<img src="./images/20250308-captcha.png" width="288" class="float-to-right">
Group owners can enable the requirement to pass captcha challenge before joining the group. Captcha is generated in the directory bot itself, without any 3rd party servers, and is sent to the joining member. The new member must reply with the text in the image to be accepted to the group. While not a perfect protection, this basic measure complicates programming automatic bots to join public groups. It also provides a foundation to implement "knocking" - a conversation with dedicated group admins prior to joining the group. We plan to release support for knocking in March.
**Profanity filter for member names**
While group settings support giving all joining member an "observer" role - that is, without the right to send messages - the attackers tried spaming groups by joining and leaving. We added an optional filter for member names that group owners can enable for groups listed in directory - if a member name contains profanity, they will be rejected. Further improvements will be released in March as well.
The current SimpleX directory chatbot is a hybrid of [future chat relays](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md#can-large-groups-scale) (a.k.a. super-peers) we are now developing to support large groups, and of a directory service that will be embedded in the app UI later this year, allowing to search and to discover public groups. Anybody is able to run their own directory bots now, and there will be possibility to use third party directories via the app UI in the future too.
Read more about [SimpleX group directory](../docs/DIRECTORY.md), how to submit your groups, and which groups we now accept. Currently we accept groups related to a limited list of topics that will be expanded once we have better moderation functionality for the groups.
### More power to group owners and moderators
This release includes two new features to help group moderators.
<img src="./images/20250308-reports.png" width="288" class="float-to-right">
**Private reports**
Group members can privately bring to group moderators attention specific messages and members, even if the group does not allow direct messages. The simply need to choose report in the message context menu and choose the report reason. This report will be visible to all group owners and moderators, but not to other members.
Group moderators can see all member reports in a separate view, and quickly find the problematic messages, making moderation much easier in public groups. These reports are private to groups, they are not sent to server operators.
Please note: in the groups listed in our directory, the directory bot acts as admin, so it will receive all reports as well.
**Acting on multiple members at once**
When attackers come, they often use multiple profiles. This version allows selecting multiple members at once and perform these actions on all selected members:
- switch members role between "observer" and "member".
- block and unblock members - this is a "shadow" block, so when you block multiple members who you believe are attackers, their messages will be blocked for all other members but not for them.
- remove members from the group.
The next version will also allow to remove members together with all messages they sent - for example, if a spam bot joined and sent a lot of spam, but nothing of value.
## Group improvements
### Mention other members and get notified when mentioned
<img src="./images/20250308-mentions.png" width="288" class="float-to-right">
This feature allows you to mention other members in the group in the usual way - type `@` character, and choose the member you want to mention from the menu. Even that there is no user accounts and persistent identities we made it work by referencing members by their random group ID that is also used for replies and all other interactions in the group.
You can also now switch message notifications in the group to "mentions only" mode. You will be notified only when you are mentioned in a message, or when somebody replies to your message. Simply choose "Mute" in the context menu of the group in the list of chats to switch group notifications to "mentions only" mode. After that you can choose "Mute all" to disable all notifications, including mentions.
### Better group performance
**Send messages faster**
We didn't reduce the required network traffic to send messages to large groups yet - your client still has to send message to each member individually. But we redesigned the process of sending a message, reducing temporary storage required to schedule the message for delivery by about 100x. This creates a significant storage saving - e.g, to send one message to a group of 1,000 members previously required ~20Mb, and now it is reduced to ~200kb. It also reduces the time and battery used to send a message.
**Faster group deletion**
When you leave the group, the app preserves a copy of all your communications in the group. You can choose to keep it or to delete it completely. This final group deletion was very slow prior to this release - depending on the number of groups on your device it could sometimes take several minutes.
This release solved this problem the time it takes to delete the group is reduced to seconds, and even in cases when the app is terminated half-way, it either rolls back or completes, but it cannot leave the group in a partially deleted state. It improves both user experience and privacy, as gives you better control over your data.
## Better chat navigation
### Organize chats into lists
<img src="./images/20250308-lists.png" width="288" class="float-to-right">
It is a common feature in many messengers it helps organizing your conversations.
The lists also show a blue mark when any chat in the list has new messages.
There are several preset lists: contacts, groups, private notes, business chats, favourite chats and also groups with member reports - the last list is automatically shown if members of any groups where you are the moderator or the owner sent private reports, until these reports are acted on or archived.
### Jump to found and forwarded messages
This version allows to quickly navigate from message in the search results to the point in the conversation when it was sent.
You can also navigate from the forwarded message (or from the message saved to private notes) to the original message in the chat where it was forwarded or saved from.
## Privacy and security improvements
### Set message retention period in chats
Before this version, you could enable message retention period for all chats in your profile. While helpful in some cases, many of us have conversations that we want to keep for a long time, and some other conversations that we want to remove quicker.
This version allows it - you can set different retention periods in different conversations. It can be 1 day, 1 week, 1 month or 1 year. We may allow custom retention time in the future.
### Private media file names
Previously there were scenarios when original media file names were preserved - e.g., when sending a video file or when forwarding any media file. The latter problem was worse, as media file name is generated automatically, and includes timestamp. So the same name could have been used to correlate files between conversations, as one of our users pointed out.
This version fixes this problem - media file name is now changed when forwarding it to match the time of forwarding, so no additional metadata is revealed.
Please also note:
- the apps remove metadata from all static images,
- iOS app removes metadata from videos, but android and desktop apps do not do it yet,
- animated images are sent as is,
- other file types are sent as is, and their names are left unchanged - we believe that for ordinary files their name is part of their content.
We plan further improvements to reduce metadata in files in the near future please let us know what you believe is the most important to reduce first.
## Reproducible server builds
Starting from v6.3 server releases are reproducible!
**Why it is important**
With reproducible builds anybody can build servers from our code following the same process, and the build would produce identical binaries.
This also allows us to sign releases, as we reproduce GitHub builds ourselves and by signing them we attest that our builds resulted in identical binaries.
**How to reproduce builds**
You can reproduce our builds on Linux with x86 CPU in docker container - please follow the instructions [here](../docs/SERVER.md#reproduce-builds).
We are looking for support from open-source contributors or security researchers who would also reproduce and sign our releases.
**How to verify release signature**
Please see the instructions [here](../docs/SERVER.md#verifying-server-binaries).
## SimpleX network
Some links to answer the most common questions:
+21 -1
View File
@@ -1,5 +1,25 @@
# Blog
Mar 3, 2025 [SimpleX Chat v6.3: new user experience and safety in public groups](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
What's new in v6.3:
- preventing spam and abuse in public groups.
- group improvements: mention other members and improved performance.
- better chat navigation: organize chats into lists and jump to found and forwarded messages.
- privacy and security improvements: chat retention period and private media file names.
Also, we added Catalan interface language to Android and desktop apps, thanks to our users and Weblate.
The last but not the least - server builds are now reproducible!
--
Jan 14, 2025 [SimpleX network: large groups and privacy-preserving content moderation](./20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption.
--
Dec 10, 2024 [SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
- SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network.
@@ -19,7 +39,7 @@ Nov 25, 2024 [Servers operated by Flux - true privacy and decentralization for a
---
Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
Oct 14, 2024 [SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience](./20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
New security audit: Trail of Bits reviewed the cryptographic design of protocols used in SimpleX network and apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

+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: a491a1d8780054432542611f540317a6090b9360
tag: aace3fd2fb146097304b09ef07ff613a8b255f67
source-repository-package
type: git
+96
View File
@@ -15,6 +15,7 @@ revision: 12.10.2024
- [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment)
- [docker container](#docker-container)
- [Linode marketplace](#linode-marketplace)
- [Verifying server binaries]
- [Configuration](#configuration)
- [Interactively](#interactively)
- [Via command line options](#via-command-line-options)
@@ -32,6 +33,7 @@ revision: 12.10.2024
- [Systemd commands](#systemd-commands)
- [Control port](#control-port)
- [Daily statistics](#daily-statistics)
- [Reproduce builds](#reproduce-builds)
- [Updating your SMP server](#updating-your-smp-server)
- [Configuring the app to use the server](#configuring-the-app-to-use-the-server)
@@ -515,6 +517,28 @@ This configuration allows you to retain the ability to manage 80 and 443 ports y
You can deploy smp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/)
## Verifying server binaries
Starting from v6.3 server builds are [reproducible](#reproduce-builds).
That also allows us to sign server releases, confirming the integrity of GitHub builds.
To verify server binaries after you downloaded them:
1. Download `_sha256sums` (hashes of all server binaries) and `_sha256sums.asc` (signature).
2. Download our key FB44AF81A45BDE327319797C85107E357D4A17FC from [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat)
3. Import the key with `gpg --import FB44AF81A45BDE327319797C85107E357D4A17FC`. Key filename should be the same as its fingerprint, but please change it if necessary.
4. Run `gpg --verify --trusted-key _sha256sums.asc _sha256sums`. It should print:
> Good signature from "SimpleX Chat <chat@simplex.chat>"
5. Compute the hashes of the binaries you plan to use with `shu256sum <file>` or with `openssl sha256 <file>` and compare them with the hashes in the file `_sha256sums` - they must be the same.
That is it - you now verified authenticity of our GitHub server binaries.
## Configuration
To see which options are available, execute `smp-server` without flags:
@@ -1564,6 +1588,78 @@ To update your smp-server to latest version, choose your installation method and
docker image prune
```
## Reproduce builds
You can locally reproduce server binaries, following these instructions.
You must have:
- Linux machine
- `x86-64` architecture
- Installed `docker`, `curl` and `git`
1. Download script:
```sh
curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/reproduce-builds.sh'
```
2. Make it executable:
```sh
chmod +x reproduce-builds.sh
```
3. Execute the script with the required tag:
```sh
./reproduce-builds.sh 'v6.3.0'
```
This will take a while.
4. After compilation, you should see the following folders:
```sh
ls out*
```
```sh
out-20.04:
ntf-server smp-server xftp xftp-server
out-20.04-github:
ntf-server smp-server xftp xftp-server
out-22.04:
ntf-server smp-server xftp xftp-server
out-22.04-github:
ntf-server smp-server xftp xftp-server
out-24.04:
ntf-server smp-server xftp xftp-server
out-24.04-github:
ntf-server smp-server xftp xftp-server
```
5. Compare the hashes from github release with locally build binaries:
```sh
sha256sum out*-github/*
```
```sh
sha256sum out*[0-9]/*
```
You can safely delete cloned repository:
```sh
cd ../ && rm -rf simplexmq
```
## Configuring the app to use the server
To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them.
+6
View File
@@ -165,6 +165,7 @@
packages.direct-sqlcipher.patches = [
./scripts/nix/direct-sqlcipher-android-log.patch
];
packages.simplex-chat.flags.client_library = true;
packages.simplexmq.flags.client_library = true;
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
(android32Pkgs.openssl.override { static = true; enableKTLS = false; })
@@ -230,6 +231,7 @@
packages.direct-sqlcipher.patches = [
./scripts/nix/direct-sqlcipher-android-log.patch
];
packages.simplex-chat.flags.client_library = true;
packages.simplexmq.flags.client_library = true;
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
(androidPkgs.openssl.override { static = true; })
@@ -297,6 +299,7 @@
packages.simplexmq.flags.swift = true;
packages.direct-sqlcipher.flags.commoncrypto = true;
packages.entropy.flags.DoNotGetEntropy = true;
packages.simplex-chat.flags.client_library = true;
packages.simplexmq.flags.client_library = true;
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
(pkgs.openssl.override { static = true; })
@@ -311,6 +314,7 @@
extra-modules = [{
packages.direct-sqlcipher.flags.commoncrypto = true;
packages.entropy.flags.DoNotGetEntropy = true;
packages.simplex-chat.flags.client_library = 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" ;}))
@@ -329,6 +333,7 @@
packages.simplexmq.flags.swift = true;
packages.direct-sqlcipher.flags.commoncrypto = true;
packages.entropy.flags.DoNotGetEntropy = true;
packages.simplex-chat.flags.client_library = true;
packages.simplexmq.flags.client_library = true;
packages.simplexmq.components.library.libs = pkgs.lib.mkForce [
(pkgs.openssl.override { static = true; })
@@ -343,6 +348,7 @@
extra-modules = [{
packages.direct-sqlcipher.flags.commoncrypto = true;
packages.entropy.flags.DoNotGetEntropy = true;
packages.simplex-chat.flags.client_library = 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' --ghc-options="-optl-L$(ghc --print-libdir)/rts -optl-Wl,--as-needed,-lHSrts_thr-ghc$GHC_VERSION" --constraint 'simplexmq +client_library'
cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN' --ghc-options="-optl-L$(ghc --print-libdir)/rts -optl-Wl,--as-needed,-lHSrts_thr-ghc$GHC_VERSION" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +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
+2 -2
View File
@@ -27,10 +27,10 @@ rm -rf $BUILD_DIR
if [[ "$DATABASE_BACKEND" == "postgres" ]]; then
echo "Building with postgres backend..."
cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library'
cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
else
echo "Building with sqlite backend..."
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library'
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
fi
cd $BUILD_DIR/build
+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 --constraint 'simplexmq +client_library'
cabal build lib:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/build/cmake
@@ -38,6 +38,22 @@
</description>
<releases>
<release version="6.3.0" date="2025-03-08">
<url type="details">https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html</url>
<description>
<p>New in v6.3.0:</p>
<ul>
<li>Mention members and get notified when mentioned.</li>
<li>Send private reports to moderators.</li>
<li>Delete, block and change role for multiple members at once</li>
<li>Faster sending messages and faster deletion.</li>
<li>Organize chats into lists to keep track of what's important.</li>
<li>Jump to found and forwarded messages.</li>
<li>Private media file names.</li>
<li>Message expiration in chats.</li>
</ul>
</description>
</release>
<release version="6.2.5" date="2025-02-16">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."a491a1d8780054432542611f540317a6090b9360" = "183wmraa25rxcf3b07apimsdvamccc3qx3p5rr726qzvpkvrxpab";
"https://github.com/simplex-chat/simplexmq.git"."aace3fd2fb146097304b09ef07ff613a8b255f67" = "0iqdarkvlakk4xmrqsg62z0vhs3kwm02l8vpr383vf8q2hd7ky75";
"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";
+32 -11
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.3.0.8
version: 6.3.1.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -24,6 +24,11 @@ flag swift
manual: True
default: False
flag client_library
description: Don't build server- and CLI-related code.
manual: True
default: False
flag client_postgres
description: Build with PostgreSQL instead of SQLite.
manual: True
@@ -33,13 +38,9 @@ library
exposed-modules:
Simplex.Chat
Simplex.Chat.AppSettings
Simplex.Chat.Bot
Simplex.Chat.Bot.KnownContacts
Simplex.Chat.Call
Simplex.Chat.Controller
Simplex.Chat.Core
Simplex.Chat.Files
Simplex.Chat.Help
Simplex.Chat.Library.Commands
Simplex.Chat.Library.Internal
Simplex.Chat.Library.Subscriber
@@ -78,18 +79,24 @@ library
Simplex.Chat.Store.Remote
Simplex.Chat.Store.Shared
Simplex.Chat.Styled
Simplex.Chat.Terminal
Simplex.Chat.Terminal.Input
Simplex.Chat.Terminal.Main
Simplex.Chat.Terminal.Notification
Simplex.Chat.Terminal.Output
Simplex.Chat.Types
Simplex.Chat.Types.Preferences
Simplex.Chat.Types.Shared
Simplex.Chat.Types.UITheme
Simplex.Chat.Types.Util
Simplex.Chat.Util
Simplex.Chat.View
if !flag(client_library)
exposed-modules:
Simplex.Chat.Bot
Simplex.Chat.Bot.KnownContacts
Simplex.Chat.Core
Simplex.Chat.Help
Simplex.Chat.Terminal
Simplex.Chat.Terminal.Input
Simplex.Chat.Terminal.Main
Simplex.Chat.Terminal.Notification
Simplex.Chat.Terminal.Output
Simplex.Chat.View
if flag(client_postgres)
exposed-modules:
Simplex.Chat.Options.Postgres
@@ -296,6 +303,8 @@ library
, text >=1.2.4.0 && <1.3
executable simplex-bot
if flag(client_library)
buildable: False
main-is: Main.hs
other-modules:
Paths_simplex_chat
@@ -313,6 +322,8 @@ executable simplex-bot
cpp-options: -DdbPostgres
executable simplex-bot-advanced
if flag(client_library)
buildable: False
main-is: Main.hs
other-modules:
Paths_simplex_chat
@@ -339,6 +350,8 @@ executable simplex-bot-advanced
text >=1.2.4.0 && <1.3
executable simplex-broadcast-bot
if flag(client_library)
buildable: False
main-is: Main.hs
hs-source-dirs:
apps/simplex-broadcast-bot
@@ -369,6 +382,8 @@ executable simplex-broadcast-bot
text >=1.2.4.0 && <1.3
executable simplex-chat
if flag(client_library)
buildable: False
main-is: Main.hs
other-modules:
Server
@@ -400,6 +415,8 @@ executable simplex-chat
text >=1.2.4.0 && <1.3
executable simplex-directory-service
if flag(client_library)
buildable: False
main-is: Main.hs
hs-source-dirs:
apps/simplex-directory-service
@@ -408,6 +425,7 @@ executable simplex-directory-service
StrictData
other-modules:
Directory.BlockedWords
Directory.Captcha
Directory.Events
Directory.Options
Directory.Search
@@ -446,6 +464,8 @@ executable simplex-directory-service
, text >=1.2.4.0 && <1.3
test-suite simplex-chat-test
if flag(client_library)
buildable: False
type: exitcode-stdio-1.0
main-is: Test.hs
other-modules:
@@ -475,6 +495,7 @@ test-suite simplex-chat-test
Broadcast.Bot
Broadcast.Options
Directory.BlockedWords
Directory.Captcha
Directory.Events
Directory.Options
Directory.Search
+2 -2
View File
@@ -23,10 +23,10 @@ import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types (Contact, ContactId, User)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON)
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
data Call = Call
+2 -1
View File
@@ -50,10 +50,11 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.Util (textParseJSON)
import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..))
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON)
import Simplex.Messaging.Protocol (BlockingInfo, MsgBody)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
+2 -1
View File
@@ -50,8 +50,9 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types (User)
import Simplex.Chat.Types.Util (textParseJSON)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol)
import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8)
+2 -2
View File
@@ -29,7 +29,6 @@ import qualified Data.Aeson.KeyMap as JM
import qualified Data.Aeson.TH as JQ
import qualified Data.Aeson.Types as JT
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (first)
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Internal (c2w, w2c)
@@ -54,11 +53,12 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Compression (Compressed, compress1, decompress1)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (MsgBody)
import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Version hiding (version)
+2 -2
View File
@@ -52,11 +52,11 @@ import Simplex.Chat.Types.Util
import Simplex.FileTransfer.Description (FileDigest)
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_)
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Version
import Simplex.Messaging.Version.Internal
+2 -1
View File
@@ -32,8 +32,9 @@ import qualified Data.Text as T
import GHC.Records.Compat
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types.Shared
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>))
data ChatFeature
+1 -1
View File
@@ -7,8 +7,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (blobFieldDecoder)
import Simplex.Messaging.Util ((<$?>))
data GroupMemberRole
+2 -1
View File
@@ -15,8 +15,9 @@ import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types.Util
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
data UITheme = UITheme
+16
View File
@@ -13,6 +13,7 @@ import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (finally)
import Control.Monad (forM_, when)
import qualified Data.Text as T
import Directory.Captcha
import qualified Directory.Events as DE
import Directory.Options
import Directory.Service
@@ -65,6 +66,8 @@ directoryServiceTests = do
it "should list user's groups" testListUserGroups
describe "store log" $ do
it "should restore directory service state" testRestoreDirectory
describe "captcha" $ do
it "should accept some incorrect spellings" testCaptcha
directoryProfile :: Profile
directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing}
@@ -974,6 +977,19 @@ testRestoreDirectory ps = do
cath #> "@SimpleX-Directory security"
groupFoundN' 2 cath "security"
testCaptcha :: HasCallStack => TestParams -> IO ()
testCaptcha _ps = do
let captcha = "23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabdefghijkmnpqrty"
matchCaptchaStr captcha captcha `shouldBe` True
matchCaptchaStr captcha "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" `shouldBe` True
matchCaptchaStr "23456789ABcDEFGH1JKLMNoPQRsTuvwxYzabdefghijkmnpqrty" captcha `shouldBe` True
matchCaptchaStr "OOIICPSUVWXZ" "OOIICPSUVWXZ" `shouldBe` True
matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxz" `shouldBe` True
matchCaptchaStr "0o1lcpsuvwxz" "OOIICPSUVWXZ" `shouldBe` True
matchCaptchaStr "OOIICPSUVWXZ" "" `shouldBe` False
matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwx" `shouldBe` False
matchCaptchaStr "OOIICPSUVWXZ" "0o1lcpsuvwxzz" `shouldBe` False
listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO ()
listGroups superUser bob cath = do
bob #> "@SimpleX-Directory /list"
+7 -6
View File
@@ -54,8 +54,8 @@ import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange)
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion)
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server.Env.STM (ServerConfig (..), StartOptions (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration)
import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..))
import Simplex.Messaging.Server.Env.STM (AServerStoreCfg (..), ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration)
import Simplex.Messaging.Server.MsgStore.Types (SQSType (..), SMSType (..))
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig)
import Simplex.Messaging.Version
@@ -476,14 +476,12 @@ smpServerCfg =
ServerConfig
{ transports = [(serverPort, transport @TLS, False)],
tbqSize = 1,
msgStoreType = AMSType SMSMemory,
msgQueueQuota = 16,
maxJournalMsgCount = 24,
maxJournalStateLines = 4,
queueIdBytes = 12,
msgIdBytes = 6,
storeLogFile = Nothing,
storeMsgsFile = Nothing,
serverStoreCfg = ASSCfg SQSMemory SMSMemory $ SSCMemory Nothing,
storeNtfsFile = Nothing,
allowNewQueues = True,
-- server password is disabled as otherwise v1 tests fail
@@ -518,9 +516,12 @@ smpServerCfg =
allowSMPProxy = True,
serverClientConcurrency = 16,
information = Nothing,
startOptions = StartOptions False False
startOptions = StartOptions {maintenance = False, compactLog = False, skipWarnings = False, confirmMigrations = MCYesUp}
}
persistentServerStoreCfg :: FilePath -> AServerStoreCfg
persistentServerStoreCfg tmp = ASSCfg SQSMemory SMSMemory $ SSCMemory $ Just StorePaths {storeLogFile = tmp <> "/smp-server-store.log", storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"}
withSmpServer :: IO () -> IO ()
withSmpServer = withSmpServer' smpServerCfg
+2 -4
View File
@@ -270,8 +270,7 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
msgQueueQuota = 2,
storeLogFile = Just $ tmp <> "/smp-server-store.log",
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
serverStoreCfg = persistentServerStoreCfg tmp
}
fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests
cfg' =
@@ -329,8 +328,7 @@ testRetryConnectingClientTimeout ps = do
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
msgQueueQuota = 2,
storeLogFile = Just $ tmp <> "/smp-server-store.log",
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
serverStoreCfg = persistentServerStoreCfg tmp
}
fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests
cfg' =
+2 -4
View File
@@ -1988,8 +1988,7 @@ testSharedMessageBody ps =
serverCfg' =
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
storeLogFile = Just $ tmp <> "/smp-server-store.log",
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
serverStoreCfg = persistentServerStoreCfg tmp
}
opts' =
testOpts
@@ -2045,8 +2044,7 @@ testSharedBatchBody ps =
serverCfg' =
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
storeLogFile = Just $ tmp <> "/smp-server-store.log",
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
serverStoreCfg = persistentServerStoreCfg tmp
}
opts' =
testOpts
+1 -2
View File
@@ -311,8 +311,7 @@ testRetryAcceptingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile b
smpServerCfg
{ transports = [("7003", transport @TLS, False)],
msgQueueQuota = 2,
storeLogFile = Just $ tmp <> "/smp-server-store.log",
storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"
serverStoreCfg = persistentServerStoreCfg tmp
}
fastRetryInterval = defaultReconnectInterval {initialInterval = 50000} -- same as in agent tests
cfg' =
@@ -0,0 +1,12 @@
<p><strong>v6.3 is released:</strong></p>
<ul class="mb-[12px]">
<li>Preventing spam and abuse in public groups.</li>
<li>Group improvements: mention other members and improved performance.</li>
<li>Better chat navigation: organize chats into lists and jump to found and forwarded messages.</li>
<li>Privacy and security improvements: chat retention period and private media file names.</li>
</ul>
<p>Also, we added Catalan interface language, thanks to our users and Weblate.</p>
<p>The last but not the least - server builds are now reproducible!</p>