Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-02-23 23:33:10 +00:00
42 changed files with 2732 additions and 940 deletions
+49 -84
View File
@@ -53,11 +53,18 @@ class ItemsModel: ObservableObject {
var itemAdded = false {
willSet { publisher.send() }
}
// set listener here that will be notified on every add/delete of a chat item
let chatState = ActiveChatState()
var chatItemsChangesListener: RecalculatePositions = RecalculatePositions()
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
@Published var showLoadingProgress: ChatId? = nil
private var navigationTimeoutTask: Task<Void, Never>? = nil
private var loadChatTask: Task<Void, Never>? = nil
init() {
publisher
@@ -67,33 +74,25 @@ class ItemsModel: ObservableObject {
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
navigationTimeoutTask?.cancel()
loadChatTask?.cancel()
navigationTimeoutTask = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
willNavigate()
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
loadChatTask = Task {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 1000_000000)
await loadChat(chatId: chatId)
if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
self.showLoadingProgress = nil
}
}
}
@@ -546,6 +545,7 @@ final class ChatModel: ObservableObject {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
@@ -591,8 +591,9 @@ final class ChatModel: ObservableObject {
// remove from current chat
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
_ = withAnimation {
im.reversedChatItems.remove(at: i)
withAnimation {
let item = im.reversedChatItems.remove(at: i)
im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
@@ -641,6 +642,7 @@ final class ChatModel: ObservableObject {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
im.reversedChatItems.insert(cItem, at: 0)
im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0)
im.itemAdded = true
}
return cItem
@@ -660,71 +662,23 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true
}
func markChatItemsRead(_ cInfo: ChatInfo) {
func markAllChatItemsRead(_ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
self.updateFloatingButtons(unreadCount: 0)
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
}
// update current chat
if chatId == cInfo.id {
markCurrentChatRead()
}
}
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < im.reversedChatItems.count {
markChatItemRead_(j)
j += 1
}
}
private func updateFloatingButtons(unreadCount: Int) {
let fbm = ChatView.FloatingButtonModel.shared
fbm.totalUnread = unreadCount
fbm.objectWillChange.send()
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
markCurrentChatRead(fromIndex: i)
_updateChat(cInfo.id) { chat in
var unreadBelow = 0
var unreadMentionsBelow = 0
var j = i - 1
while j >= 0 {
let meta = self.im.reversedChatItems[j].meta
if case .rcvNew = meta.itemStatus {
unreadBelow += 1
if meta.userMention {
unreadMentionsBelow += 1
}
}
j -= 1
}
// update preview
let markedCount = chat.chatStats.unreadCount - unreadBelow
let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow
if markedCount > 0 || markedMentionsCount > 0 {
let wasUnread = chat.unreadTag
chat.chatStats.unreadCount -= markedCount
chat.chatStats.unreadMentions -= markedMentionsCount
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: by)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
}
var i = 0
while i < im.reversedChatItems.count {
markChatItemRead_(i)
i += 1
}
} else {
markChatItemsRead(cInfo)
im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed())
}
}
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
_updateChat(cInfo.id) { chat in
let wasUnread = chat.unreadTag
@@ -746,16 +700,25 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
im.chatItemsChangesListener.cleared()
}
}
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
if self.chatId == cInfo.id {
for itemId in itemIds {
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
var unreadItemIds: Set<ChatItem.ID> = []
var i = 0
var ids = Set(itemIds)
while i < im.reversedChatItems.count && !ids.isEmpty {
let item = im.reversedChatItems[i]
if ids.contains(item.id) && item.isRcvNew {
markChatItemRead_(i)
unreadItemIds.insert(item.id)
ids.remove(item.id)
}
i += 1
}
im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed())
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
@@ -783,9 +746,6 @@ final class ChatModel: ObservableObject {
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
if chatId == ChatModel.shared.chatId {
ChatView.FloatingButtonModel.shared.totalUnread += count
}
let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
subject.send()
@@ -979,12 +939,17 @@ final class ChatModel: ObservableObject {
// returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
let items = im.reversedChatItems
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
if i < items.count {
if case let .groupRcv(m) = items[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
} else {
logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
}
}
return (prevMember, memberIds.count)
+17 -28
View File
@@ -328,38 +328,27 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] {
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
throw r
}
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
if case let .apiChat(_, chat) = r { return chat.chatItems }
throw r
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
}
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
if clearItems {
await MainActor.run { im.reversedChatItems = [] }
func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async {
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
if clearItems {
await MainActor.run {
im.reversedChatItems = []
ItemsModel.shared.chatItemsChangesListener.cleared()
}
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
if (replaceChat) {
m.replaceChat(chat.chatInfo.id, chat)
}
}
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 })
}
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
@@ -869,7 +858,7 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
throw r
}
@@ -1524,7 +1513,7 @@ func markChatRead(_ chat: Chat) async {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
}
}
if chat.chatStats.unreadChat {
@@ -2172,7 +2161,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
if let hostConn = hostMember.activeConn {
@@ -684,17 +684,23 @@ struct ChatTTLOption: View {
) {
progressIndicator = true
Task {
let m = ChatModel.shared
do {
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
await loadChat(chat: chat, clearItems: true, replaceChat: true)
await loadChat(chat: chat, clearItems: true)
await MainActor.run {
progressIndicator = false
currentChatItemTTL = chatItemTTL
if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id,
let chat = m.getChat(chat.id) {
chat.chatItems = []
m.replaceChat(chat.id, chat)
}
}
}
catch let error {
logger.error("setChatTTL error \(responseError(error))")
await loadChat(chat: chat, clearItems: true, replaceChat: true)
await loadChat(chat: chat, clearItems: true)
await MainActor.run {
chatItemTTL = currentChatItemTTL
progressIndicator = false
@@ -293,16 +293,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@@ -25,7 +26,7 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
@@ -498,10 +498,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -92,11 +92,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
File diff suppressed because one or more lines are too long
@@ -13,8 +13,8 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
scrollModel.scrollToItem(id: chatItem.id)
scrollToItemId?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
+23 -14
View File
@@ -35,18 +35,21 @@ struct ChatItemView: View {
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
var scrollToItemId: (ChatItem.ID) -> Void
var maxWidth: CGFloat = .infinity
@Binding var allowMenu: Bool
init(
chat: Chat,
chatItem: ChatItem,
scrollToItemId: @escaping (ChatItem.ID) -> Void,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
allowMenu: Binding<Bool> = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
self.scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
_allowMenu = allowMenu
}
@@ -90,6 +93,7 @@ struct ChatItemView: View {
return FramedItemView(
chat: chat,
chatItem: chatItem,
scrollToItemId: scrollToItemId,
preview: preview,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
@@ -244,15 +248,15 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
@@ -272,7 +276,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
)
),
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -282,7 +287,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
quotedItem: nil,
file: nil
)
),
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -292,7 +298,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
)
),
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -302,7 +309,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
)
),
scrollToItemId: { _ in }
)
ChatItemView(
chat: Chat.sampleData,
@@ -312,7 +320,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: ciFeatureContent,
quotedItem: nil,
file: nil
)
),
scrollToItemId: { _ in }
)
}
.environment(\.revealed, true)
@@ -0,0 +1,328 @@
//
// ChatItemsLoader.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 17.12.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SimpleXChat
import SwiftUI
let TRIM_KEEP_COUNT = 200
func apiLoadMessages(
_ chatId: ChatId,
_ pagination: ChatPagination,
_ chatState: ActiveChatState,
_ search: String = "",
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
) async {
let chat: Chat
let navInfo: NavigationInfo
do {
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
}
let chatModel = ChatModel.shared
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
let paginationIsInitial = switch pagination { case .initial: true; default: false }
let paginationIsLast = switch pagination { case .last: true; default: false }
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled {
return
}
let unreadAfterItemId = chatState.unreadAfterItemId
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
var newItems: [ChatItem] = []
switch pagination {
case .initial:
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
if chatModel.getChat(chat.id) == nil {
chatModel.addChat(chat)
}
await MainActor.run {
chatModel.chatItemStatuses.removeAll()
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
chatModel.updateChatInfo(chat.chatInfo)
chatState.splits = newSplits
if !chat.chatItems.isEmpty {
chatState.unreadAfterItemId = chat.chatItems.last!.id
}
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
guard let indexInCurrentItems else { return }
let (newIds, _) = mapItemsToIds(chat.chatItems)
let wasSize = newItems.count
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
)
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = modifiedSplits.newSplits
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
}
case let .after(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
guard let indexInCurrentItems else { return }
let mappedItems = mapItemsToIds(chat.chatItems)
let newIds = mappedItems.0
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
)
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
let indexToAddIsLast = indexToAdd == newItems.count
newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
let new: [ChatItem] = newItems
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = newSplits
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
// loading clear bottom area, updating number of unread items after the newest loaded item
if indexToAddIsLast {
chatState.unreadAfterNewestLoaded -= unreadInLoaded
}
}
case .around:
newItems.append(contentsOf: oldItems)
let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
// currently, items will always be added on top, which is index 0
newItems.insert(contentsOf: chat.chatItems, at: 0)
let newReversed: [ChatItem] = newItems.reversed()
await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = [chat.chatItems.last!.id] + newSplits
chatState.unreadAfterItemId = chat.chatItems.last!.id
chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread
// no need to set it, count will be wrong
// unreadAfterNewestLoaded.value = navInfo.afterUnread
}
case .last:
newItems.append(contentsOf: oldItems)
removeDuplicates(&newItems, chat)
newItems.append(contentsOf: chat.chatItems)
let items = newItems
await MainActor.run {
ItemsModel.shared.reversedChatItems = items.reversed()
chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0
}
}
}
private class ModifiedSplits {
let oldUnreadSplitIndex: Int
let newUnreadSplitIndex: Int
let trimmedIds: Set<Int64>
let newSplits: [Int64]
init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set<Int64>, newSplits: [Int64]) {
self.oldUnreadSplitIndex = oldUnreadSplitIndex
self.newUnreadSplitIndex = newUnreadSplitIndex
self.trimmedIds = trimmedIds
self.newSplits = newSplits
}
}
private func removeDuplicatesAndModifySplitsOnBeforePagination(
_ unreadAfterItemId: Int64,
_ newItems: inout [ChatItem],
_ newIds: Set<Int64>,
_ splits: [Int64],
_ visibleItemIndexes: ClosedRange<Int>
) -> ModifiedSplits {
var oldUnreadSplitIndex: Int = -1
var newUnreadSplitIndex: Int = -1
var lastSplitIndexTrimmed: Int? = nil
var allowedTrimming = true
var index = 0
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT
let trimUpperBound = newItems.count - TRIM_KEEP_COUNT
let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1
var trimmedIds = Set<Int64>()
let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1
let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT
let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1
var newSplits = splits
newItems.removeAll(where: {
let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
// may disable it after clearing the whole split range
if !splits.isEmpty && $0.id == splits.first {
// trim only in one split range
allowedTrimming = false
}
let indexInSplits = splits.firstIndex(of: $0.id)
if let indexInSplits {
lastSplitIndexTrimmed = indexInSplits
}
if invisibleItemToTrim {
if prevItemWasTrimmed {
trimmedIds.insert($0.id)
} else {
newUnreadSplitIndex = index
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
if let lastSplitIndexTrimmed {
var new = newSplits
new[lastSplitIndexTrimmed] = $0.id
newSplits = new
} else {
newSplits = [$0.id] + newSplits
}
}
}
if unreadAfterItemId == $0.id {
oldUnreadSplitIndex = index
}
index += 1
return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id)
})
// will remove any splits that now becomes obsolete because items were merged
newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) }
return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits)
}
private func removeDuplicatesAndModifySplitsOnAfterPagination(
_ unreadInLoaded: Int,
_ paginationChatItemId: Int64,
_ newItems: inout [ChatItem],
_ newIds: Set<Int64>,
_ chat: Chat,
_ splits: [Int64]
) -> ([Int64], Int) {
var unreadInLoaded = unreadInLoaded
var firstItemIdBelowAllSplits: Int64? = nil
var splitsToRemove: Set<Int64> = []
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])
} else {
[]
}
newItems.removeAll(where: { new in
let duplicate = newIds.contains(new.id)
if loadingFromSplitRange && duplicate {
if splitsToMerge.contains(new.id) {
splitsToMerge.removeAll(where: { $0 == new.id })
splitsToRemove.insert(new.id)
} else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty {
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
firstItemIdBelowAllSplits = new.id
}
}
if duplicate && new.isRcvNew {
unreadInLoaded -= 1
}
return duplicate
})
var newSplits: [Int64] = []
if firstItemIdBelowAllSplits != nil {
// no splits anymore, all were merged with bottom items
newSplits = []
} else {
if !splitsToRemove.isEmpty {
var new = splits
new.removeAll(where: { splitsToRemove.contains($0) })
newSplits = new
}
let enlargedSplit = splits.firstIndex(of: paginationChatItemId)
if let enlargedSplit {
// move the split to the end of loaded items
var new = splits
new[enlargedSplit] = chat.chatItems.last!.id
newSplits = new
}
}
return (newSplits, unreadInLoaded)
}
private func removeDuplicatesAndUpperSplits(
_ newItems: inout [ChatItem],
_ chat: Chat,
_ splits: [Int64],
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int>
) async -> [Int64] {
if splits.isEmpty {
removeDuplicates(&newItems, chat)
return splits
}
var newSplits = splits
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let (newIds, _) = mapItemsToIds(chat.chatItems)
var idsToTrim: [BoxedValue<Set<Int64>>] = []
idsToTrim.append(BoxedValue(Set()))
var index = 0
newItems.removeAll(where: {
let duplicate = newIds.contains($0.id)
if (!duplicate && visibleItemIndexes.lowerBound > index) {
idsToTrim.last?.boxedValue.insert($0.id)
}
if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) {
newSplits.remove(at: firstIndex)
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
idsToTrim.append(BoxedValue(Set()))
}
index += 1
return duplicate
})
if !idsToTrim.last!.boxedValue.isEmpty {
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
// Otherwise, the last set would be empty
idsToTrim.removeLast()
}
let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined()
if !allItemsToDelete.isEmpty {
newItems.removeAll(where: { allItemsToDelete.contains($0.id) })
}
return newSplits
}
// ids, number of unread items
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
var unreadInLoaded = 0
var ids: Set<Int64> = Set()
var i = 0
while i < items.count {
let item = items[i]
ids.insert(item.id)
if item.isRcvNew {
unreadInLoaded += 1
}
i += 1
}
return (ids, unreadInLoaded)
}
private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
let (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains($0.id) }
}
@@ -0,0 +1,459 @@
//
// ChatItemsMerger.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 02.12.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct MergedItems: Hashable, Equatable {
let items: [MergedItem]
let splits: [SplitRange]
// chat item id, index in list
let indexInParentItems: Dictionary<Int64, Int>
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hashValue == rhs.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine("\(items.hashValue)")
}
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
if items.isEmpty {
return MergedItems(items: [], splits: [], indexInParentItems: [:])
}
let unreadCount = chatState.unreadTotal
let unreadAfterItemId = chatState.unreadAfterItemId
let itemSplits = chatState.splits
var mergedItems: [MergedItem] = []
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
var splitRanges: [SplitRange] = []
var indexInParentItems = Dictionary<Int64, Int>()
var index = 0
var unclosedSplitIndex: Int? = nil
var unclosedSplitIndexInParent: Int? = nil
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
var recent: MergedItem? = nil
while index < items.count {
let item = items[index]
let prev = index >= 1 ? items[index - 1] : nil
let next = index + 1 < items.count ? items[index + 1] : nil
let category = item.mergeCategory
let itemIsSplit = itemSplits.contains(item.id)
if item.id == unreadAfterItemId {
unreadBefore = unreadCount - chatState.unreadAfter
}
if item.isRcvNew {
unreadBefore -= 1
}
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
items.boxedValue.append(listItem)
if item.isRcvNew {
unreadIds.boxedValue.insert(item.id)
}
if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
if revealed {
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
}
lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
}
} else {
visibleItemIndexInParent += 1
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
if item.mergeCategory != nil {
if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : [])
} else if revealed, let lastRevealedIdsInMergedItems {
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
}
lastRangeInReversedForMergedItems = BoxedValue(index ... index)
recent = MergedItem.grouped(
items: BoxedValue([listItem]),
revealed: revealed,
revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
rangeInReversed: lastRangeInReversedForMergedItems!,
mergeCategory: item.mergeCategory,
unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()),
startIndexInReversedItems: index,
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
)
} else {
lastRangeInReversedForMergedItems = nil
recent = MergedItem.single(
item: listItem,
startIndexInReversedItems: index,
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
)
}
mergedItems.append(recent!)
}
if itemIsSplit {
// found item that is considered as a split
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
// it was at least second split in the list
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
// just one split for the whole list, there will be no more, it's the end
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index += 1
}
return MergedItems(
items: mergedItems,
splits: splitRanges,
indexInParentItems: indexInParentItems
)
}
// Use this check to ensure that mergedItems state based on currently actual state of global
// splits and reversedChatItems
func isActualState() -> Bool {
let im = ItemsModel.shared
// do not load anything if global splits state is different than in merged items because it
// will produce undefined results in terms of loading and placement of items.
// Same applies to reversedChatItems
return indexInParentItems.count == im.reversedChatItems.count &&
splits.count == im.chatState.splits.count &&
// that's just an optimization because most of the time only 1 split exists
((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted())
}
}
enum MergedItem: Identifiable, Hashable, Equatable {
// equatable and hashable implementations allows to see the difference and correctly scroll to items we want
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hash == rhs.hash
}
var id: Int64 { newest().item.id }
func hash(into hasher: inout Hasher) {
hasher.combine(hash)
}
var hash: String {
switch self {
case .single(_, _, let hash): hash + " 1"
case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)"
}
}
// the item that is always single, cannot be grouped and always revealed
case single(
item: ListItem,
startIndexInReversedItems: Int,
hash: String
)
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
* visible items in ChatView's EndlessScrollView */
case grouped (
items: BoxedValue<[ListItem]>,
revealed: Bool,
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
// it's the same list instance for all Grouped items within revealed group
/** @see reveal */
revealedIdsWithinGroup: BoxedValue<[Int64]>,
rangeInReversed: BoxedValue<ClosedRange<Int>>,
mergeCategory: CIMergeCategory?,
unreadIds: BoxedValue<Set<Int64>>,
startIndexInReversedItems: Int,
hash: String
)
func revealItems(_ reveal: Bool, _ revealedItems: Binding<Set<Int64>>) {
if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self {
var newRevealed = revealedItems.wrappedValue
var i = 0
if reveal {
while i < items.boxedValue.count {
newRevealed.insert(items.boxedValue[i].item.id)
i += 1
}
} else {
while i < revealedIdsWithinGroup.boxedValue.count {
newRevealed.remove(revealedIdsWithinGroup.boxedValue[i])
i += 1
}
revealedIdsWithinGroup.boxedValue.removeAll()
}
revealedItems.wrappedValue = newRevealed
}
}
var startIndexInReversedItems: Int {
get {
switch self {
case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems
case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems
}
}
}
func hasUnread() -> Bool {
switch self {
case let .single(item, _, _): item.item.isRcvNew
case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty
}
}
func newest() -> ListItem {
switch self {
case let .single(item, _, _): item
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0]
}
}
func oldest() -> ListItem {
switch self {
case let .single(item, _, _): item
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1]
}
}
func lastIndexInReversed() -> Int {
switch self {
case .single: startIndexInReversedItems
case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1
}
}
}
struct SplitRange {
let itemId: Int64
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
* (3, 4 indexes of the splitRange with the split itself at index 3)
* */
let indexRangeInReversed: ClosedRange<Int>
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
let indexRangeInParentItems: ClosedRange<Int>
}
struct ListItem: Hashable {
let item: ChatItem
let prevItem: ChatItem?
let nextItem: ChatItem?
// how many unread items before (older than) this one (excluding this one)
let unreadBefore: Int
private func chatDirHash(_ chatDir: CIDirection?) -> Int {
guard let chatDir else { return 0 }
return switch chatDir {
case .directSnd: 0
case .directRcv: 1
case .groupSnd: 2
case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
case .localSnd: 4
case .localRcv: 5
}
}
// using meta.hashValue instead of parts takes much more time so better to use partial meta here
func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String {
"\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)"
}
}
class ActiveChatState {
var splits: [Int64] = []
var unreadAfterItemId: Int64 = -1
// total items after unread after item (exclusive)
var totalAfter: Int = 0
var unreadTotal: Int = 0
// exclusive
var unreadAfter: Int = 0
// exclusive
var unreadAfterNewestLoaded: Int = 0
func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) {
guard let toItemId else { return }
let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId })
let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId })
guard let currentIndex, let newIndex else {
return
}
unreadAfterItemId = toItemId
let unreadDiff = newIndex > currentIndex
? -nonReversedItems[currentIndex + 1..<newIndex + 1].filter { $0.isRcvNew }.count
: nonReversedItems[newIndex + 1..<currentIndex + 1].filter { $0.isRcvNew }.count
unreadAfter += unreadDiff
}
func moveUnreadAfterItem(_ fromIndex: Int, _ toIndex: Int, _ nonReversedItems: [ChatItem]) {
if fromIndex == -1 || toIndex == -1 {
return
}
unreadAfterItemId = nonReversedItems[toIndex].id
let unreadDiff = toIndex > fromIndex
? -nonReversedItems[fromIndex + 1..<toIndex + 1].filter { $0.isRcvNew }.count
: nonReversedItems[toIndex + 1..<fromIndex + 1].filter { $0.isRcvNew }.count
unreadAfter += unreadDiff
}
func clear() {
splits = []
unreadAfterItemId = -1
totalAfter = 0
unreadTotal = 0
unreadAfter = 0
unreadAfterNewestLoaded = 0
}
}
class BoxedValue<T: Hashable>: Equatable, Hashable {
static func == (lhs: BoxedValue<T>, rhs: BoxedValue<T>) -> Bool {
lhs.boxedValue == rhs.boxedValue
}
func hash(into hasher: inout Hasher) {
hasher.combine("\(self)")
}
var boxedValue : T
init(_ value: T) {
self.boxedValue = value
}
}
@MainActor
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
let zero = 0 ... 0
let items = mergedItems.items
if items.isEmpty {
return zero
}
let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
guard let newest, let oldest else {
return zero
}
let size = ItemsModel.shared.reversedChatItems.count
let range = size - oldest ... size - newest
if range.lowerBound < 0 || range.upperBound < 0 {
return zero
}
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
return range
}
class RecalculatePositions {
private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } }
func read(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
guard let itemIds else {
// special case when the whole chat became read
chatState.unreadTotal = 0
chatState.unreadAfter = 0
return
}
var unreadAfterItemIndex: Int = -1
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
var i = newItems.count - 1
var ids = itemIds
// intermediate variables to prevent re-setting state value a lot of times without reason
var newUnreadTotal = chatState.unreadTotal
var newUnreadAfter = chatState.unreadAfter
while i >= 0 {
let item = newItems[i]
if item.id == chatState.unreadAfterItemId {
unreadAfterItemIndex = i
}
if ids.contains(item.id) {
// was unread, now this item is read
if (unreadAfterItemIndex == -1) {
newUnreadAfter -= 1
}
newUnreadTotal -= 1
ids.remove(item.id)
if ids.isEmpty {
break
}
}
i -= 1
}
chatState.unreadTotal = newUnreadTotal
chatState.unreadAfter = newUnreadAfter
}
func added(_ item: (Int64, Bool), _ index: Int) {
if item.1 {
chatState.unreadAfter += 1
chatState.unreadTotal += 1
}
}
func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
var newSplits: [Int64] = []
for split in chatState.splits {
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
// deleted the item that was right before the split between items, find newer item so it will act like the split
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
// it the whole section is gone and splits overlap, don't add it at all
if let newSplit, !newSplits.contains(newSplit) {
newSplits.append(newSplit)
}
} else {
newSplits.append(split)
}
}
chatState.splits = newSplits
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId })
// unread after item was removed
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
if newUnreadAfterItemId == nil {
// everything on top (including unread after item) were deleted, take top item as unread after id
newUnreadAfterItemId = newItems.first?.id
}
if let newUnreadAfterItemId {
chatState.unreadAfterItemId = newUnreadAfterItemId
chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
if newUnreadAfterItemWasNull {
// since the unread after item was moved one item after initial position, adjust counters accordingly
if newItems.first?.isRcvNew == true {
chatState.unreadTotal += 1
chatState.unreadAfter -= 1
}
}
} else {
// all items were deleted, 0 items in chatItems
chatState.unreadAfterItemId = -1
chatState.totalAfter = 0
chatState.unreadTotal = 0
chatState.unreadAfter = 0
}
} else {
chatState.totalAfter -= itemIds.count
}
}
func cleared() { chatState.clear() }
}
@@ -0,0 +1,165 @@
//
// ChatScrollHelpers.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 20.12.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) {
if ItemsModel.shared.chatState.totalAfter == 0 {
return
}
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)
await MainActor.run {
loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
}
}
}
class PreloadState {
static let shared = PreloadState()
var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0
var preloading: Bool = false
}
func preloadIfNeeded(
_ allowLoadMoreItems: Binding<Bool>,
_ ignoreLoadingRequests: Binding<Int64?>,
_ listState: EndlessScrollView<MergedItem>.ListState,
_ mergedItems: BoxedValue<MergedItems>,
loadItems: @escaping (Bool, ChatPagination) async -> Bool
) {
let state = PreloadState.shared
guard !listState.isScrolling,
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
}
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
await loadItems(false, pagination)
}
}
}
func preloadItems(
_ mergedItems: MergedItems,
_ allowLoadMoreItems: Bool,
_ listState: EndlessScrollView<MergedItem>.ListState,
_ ignoreLoadingRequests: Binding<Int64?>,
_ loadItems: @escaping (ChatPagination) async -> Bool)
async {
let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1
let remaining = ChatPagination.UNTIL_PRELOAD_COUNT
let firstVisibleIndex = listState.firstVisibleItemIndex
if !(await preloadItemsBefore()) {
await preloadItemsAfter()
}
func preloadItemsBefore() async -> Bool {
let splits = mergedItems.splits
let lastVisibleIndex = listState.lastVisibleItemIndex
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
lastIndexToLoadFrom = items.count - 1
}
let loadFromItemId: Int64?
if allowLoad, let lastIndexToLoadFrom {
let index = items.count - 1 - lastIndexToLoadFrom
loadFromItemId = index >= 0 ? items[index].id : nil
} else {
loadFromItemId = nil
}
guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else {
return false
}
let sizeWas = items.count
let firstItemIdWas = items.first?.id
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 triedToLoad
}
func preloadItemsAfter() async {
let splits = mergedItems.splits
let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
let index = split.indexRangeInReversed.lowerBound
if index >= 0 {
let loadFromItemId = reversedItems[index].id
_ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
}
}
}
}
func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView<MergedItem>.ListState) -> ListItem? {
if listState.lastVisibleItemIndex < listState.items.count {
return listState.items[listState.lastVisibleItemIndex].oldest()
} else {
return listState.items.last?.oldest()
}
}
private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? {
for split in splits {
// before any split
if split.indexRangeInParentItems.lowerBound > firstVisibleIndex {
if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) {
return split.indexRangeInReversed.lowerBound - 1
}
break
}
let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex)
if containsInRange {
if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) {
return split.indexRangeInReversed.upperBound
}
break
}
}
return nil
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
File diff suppressed because it is too large Load Diff
@@ -392,7 +392,7 @@ struct ComposeView: View {
}
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.bottom, 16)
.padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo,
@@ -20,15 +20,9 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37
static let minHeight: CGFloat = 39
private let defaultHeight: CGFloat = {
let field = CustomUITextField(parent: nil, height: Binding.constant(0))
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
}()
func makeUIView(context: Context) -> UITextView {
func makeUIView(context: Context) -> CustomUITextField {
let field = CustomUITextField(parent: self, height: _height)
field.backgroundColor = .clear
field.text = text
@@ -38,10 +32,9 @@ struct NativeTextEditor: UIViewRepresentable {
if !disableEditing {
text = newText
field.textAlignment = alignment(text)
updateFont(field)
field.updateFont()
// Speed up the process of updating layout, reduce jumping content on screen
updateHeight(field)
self.height = field.frame.size.height
field.updateHeight()
} else {
field.text = text
}
@@ -53,63 +46,38 @@ struct NativeTextEditor: UIViewRepresentable {
field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
field.setPlaceholderView()
updateFont(field)
updateHeight(field)
field.updateFont()
field.updateHeight(updateBindingNow: false)
return field
}
func updateUIView(_ field: UITextView, context: Context) {
func updateUIView(_ field: CustomUITextField, context: Context) {
if field.markedTextRange == nil && field.text != text {
field.text = text
field.textAlignment = alignment(text)
updateFont(field)
updateHeight(field)
field.updateFont()
field.updateHeight(updateBindingNow: false)
}
let castedField = field as! CustomUITextField
if castedField.placeholder != placeholder {
castedField.placeholder = placeholder
if field.placeholder != placeholder {
field.placeholder = placeholder
}
if field.selectedRange != selectedRange {
field.selectedRange = selectedRange
}
}
private func updateHeight(_ field: UITextView) {
let maxHeight = min(360, field.font!.lineHeight * 12)
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
let newHeight = field.text == ""
? defaultHeight
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
if field.frame.size.height != newHeight {
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
}
}
private func updateFont(_ field: UITextView) {
let newFont = isShortEmoji(field.text)
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
: UIFont.preferredFont(forTextStyle: .body)
if field.font != newFont {
field.font = newFont
}
}
}
private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left
}
private class CustomUITextField: UITextView, UITextViewDelegate {
class CustomUITextField: UITextView, UITextViewDelegate {
var parent: NativeTextEditor?
var height: Binding<CGFloat>
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
private let placeholderLabel: UILabel = UILabel()
init(parent: NativeTextEditor?, height: Binding<CGFloat>) {
@@ -135,11 +103,44 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
if height.wrappedValue != newHeight {
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
func updateHeight(updateBindingNow: Bool = true) {
let maxHeight = min(360, font!.lineHeight * 12)
let newHeight = min(max(sizeThatFits(CGSizeMake(frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down)
if self.newHeight != newHeight {
frame.size = CGSizeMake(frame.size.width, newHeight)
invalidateIntrinsicContentHeight(newHeight)
if updateBindingNow {
self.height.wrappedValue = newHeight
} else {
DispatchQueue.main.async {
self.height.wrappedValue = newHeight
}
}
}
return CGSizeMake(0, newHeight)
}
func updateFont() {
let newFont = isShortEmoji(text)
? (text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
: UIFont.preferredFont(forTextStyle: .body)
if font != newFont {
font = newFont
// force apply new font because it has problem with doing it when the field had two emojis
if text.count == 0 {
text = " "
text = ""
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateHeight()
}
override var intrinsicContentSize: CGSize {
CGSizeMake(0, newHeight)
}
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
@@ -33,7 +33,7 @@ struct SendMessageView: View {
@Namespace var namespace
@Binding var keyboardVisible: Bool
var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42
@State private var teHeight: CGFloat = NativeTextEditor.minHeight
@State private var teFont: Font = .body
@State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1
@@ -44,53 +44,53 @@ struct SendMessageView: View {
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
ZStack {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview {
Text("Voice message…")
.font(teFont.italic())
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
} else {
NativeTextEditor(
text: $composeState.message,
disableEditing: $composeState.inProgress,
height: $teHeight,
focused: $keyboardVisible,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
selectedRange: $selectedRange,
onImagesAdded: onMediaAdded
)
.allowsTightening(false)
.fixedSize(horizontal: false, vertical: true)
}
}
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 3)
} else {
VStack(alignment: .trailing) {
if teHeight > 100 && !composeState.inProgress {
deleteTextButton()
Spacer()
}
composeActionButtons()
}
.frame(height: teHeight, alignment: .bottom)
}
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview {
Text("Voice message…")
.font(teFont.italic())
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.trailing, 32)
.frame(maxWidth: .infinity)
} else {
NativeTextEditor(
text: $composeState.message,
disableEditing: $composeState.inProgress,
height: $teHeight,
focused: $keyboardVisible,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
selectedRange: $selectedRange,
onImagesAdded: onMediaAdded
)
.padding(.trailing, 32)
.allowsTightening(false)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
}
.overlay(alignment: .topTrailing, content: {
if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
deleteTextButton()
}
})
.overlay(alignment: .bottomTrailing, content: {
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 4)
} else {
composeActionButtons()
// required for intercepting clicks
.background(.white.opacity(0.000001))
}
})
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
if inProgress {
@@ -169,7 +169,7 @@ struct SendMessageView: View {
!composeState.sendEnabled ||
composeState.inProgress
)
.frame(width: 29, height: 29)
.frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -192,7 +192,7 @@ struct SendMessageView: View {
composeState.endLiveDisabled ||
disableSendButton
)
.frame(width: 29, height: 29)
.frame(width: 31, height: 31)
.contextMenu{
sendButtonContextMenuItems()
}
@@ -269,7 +269,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.primary)
}
.disabled(disabled)
.frame(width: 29, height: 29)
.frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
._onButtonGesture { down in
if down {
@@ -325,7 +325,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -410,7 +410,7 @@ struct SendMessageView: View {
.foregroundColor(theme.colors.primary)
}
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
@@ -0,0 +1,692 @@
//
// EndlessScrollView.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 25.01.2025.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ScrollRepresentable<Content: View, ScrollItem>: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
let scrollView: EndlessScrollView<ScrollItem>
let content: (Int, ScrollItem) -> Content
func makeUIViewController(context: Context) -> ScrollController {
ScrollController.init(scrollView: scrollView, content: content)
}
func updateUIViewController(_ controller: ScrollController, context: Context) {}
class ScrollController: UIViewController {
let scrollView: EndlessScrollView<ScrollItem>
fileprivate var items: [ScrollItem] = []
fileprivate var content: ((Int, ScrollItem) -> Content)!
fileprivate init(scrollView: EndlessScrollView<ScrollItem>, content: @escaping (Int, ScrollItem) -> Content) {
self.scrollView = scrollView
self.content = content
super.init(nibName: nil, bundle: nil)
self.view = scrollView
scrollView.createCell = createCell
scrollView.updateCell = updateCell
}
required init?(coder: NSCoder) { fatalError() }
private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView {
let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil
let cell: UIView
if #available(iOS 16.0, *), false {
let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell
if let item {
c.contentConfiguration = UIHostingConfiguration { self.content(index, item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
}
cell = c
} else {
let c = cellsToReuse.isEmpty ? HostingCell<Content>() : cellsToReuse.removeLast() as! HostingCell<Content>
if let item {
c.set(content: self.content(index, item), parent: self)
}
cell = c
}
cell.isHidden = false
cell.backgroundColor = .clear
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
cell.frame.size.width = scrollView.bounds.width
cell.frame.size.height = size.height
return cell
}
private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) {
let item = items[index]
if #available(iOS 16.0, *), false {
(cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.content(index, item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
cell.frame.size.width = scrollView.bounds.width
cell.frame.size.height = size.height
cell.setNeedsLayout()
}
}
}
class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable {
/// Stores actual state of the scroll view and all elements drawn on the screen
let listState: ListState = ListState()
/// Just some random big number that will probably be enough to scrolling down and up without reaching the end
var initialOffset: CGFloat = 100000000
/// Default item id when no items in the visible items list. Something that will never be in real data
fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } }
/// Storing an offset that was already used for laying down content to be able to see the difference
var prevProcessedOffset: CGFloat = 0
/// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content
/// starts from bottom and ends at top, not vice versa as usual
var oldScreenHeight: CGFloat = 0
/// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen
var estimatedContentHeight: ContentHeight = ContentHeight()
/// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too
var averageItemHeight: CGFloat = 30
/// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed
var scrollStepMultiplier: CGFloat = 0.37
/// Adds content padding to top
var insetTop: CGFloat = 100
/// Adds content padding to bottom
var insetBottom: CGFloat = 100
var scrollToItemIndexDelayed: Int? = nil
/// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight]
let scrollBarView: UIScrollView = UIScrollView(frame: .zero)
/// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch
var cellsToReuse: [UIView] = []
/// Enable debug to see hundreds of logs
var debug: Bool = false
var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil }
var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in }
override init(frame: CGRect) {
super.init(frame: frame)
self.delegate = self
}
required init?(coder: NSCoder) { fatalError() }
class ListState: NSObject {
/// Will be called on every change of the items array, visible items, and scroll position
var onUpdateListener: () -> Void = {}
/// Items that were used to lay out the screen
var items: [ScrollItem] = [] {
didSet {
onUpdateListener()
}
}
/// It is equai to the number of [items]
var totalItemsCount: Int {
items.count
}
/// The items with their positions and other useful information. Only those that are visible on screen
var visibleItems: [EndlessScrollView<ScrollItem>.VisibleItem] = []
/// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen
var firstVisibleItemIndex: Int = 0
/// Unique item id of the first visible item on screen
var firstVisibleItemId: any Hashable = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
/// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view
var firstVisibleItemOffset: CGFloat = -100
/// Index of the last visible item on screen
var lastVisibleItemIndex: Int {
visibleItems.last?.index ?? 0
}
/// Whether there is scroll to item in progress or not
var isScrolling: Bool = false
override init() {
super.init()
}
}
class VisibleItem {
let index: Int
let item: ScrollItem
let view: UIView
var offset: CGFloat
init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) {
self.index = index
self.item = item
self.view = view
self.offset = offset
}
}
class ContentHeight {
/// After that you should see overscroll effect. When scroll positon is far from
/// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example:
/// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height
var topOffsetY: CGFloat = 0
var bottomOffsetY: CGFloat = 0
var virtualScrollOffsetY: CGFloat = 0
/// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView
var overscrolledTop: CGFloat = 0
/// Adds content padding to bottom and top
var inset: CGFloat = 100
/// Estimated height of the contents of scroll view
var height: CGFloat {
get { bottomOffsetY - topOffsetY }
}
/// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar
var virtualOverscrolledHeight: CGFloat {
get {
bottomOffsetY - topOffsetY + overscrolledTop - inset * 2
}
}
func update(
_ contentOffset: CGPoint,
_ listState: ListState,
_ averageItemHeight: CGFloat,
_ updateStaleHeight: Bool
) {
let lastVisible = listState.visibleItems.last
let firstVisible = listState.visibleItems.first
guard let last = lastVisible, let first = firstVisible else {
topOffsetY = contentOffset.y
bottomOffsetY = contentOffset.y
virtualScrollOffsetY = 0
overscrolledTop = 0
return
}
topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset
bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset
virtualScrollOffsetY = contentOffset.y - topOffsetY
overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0)
}
}
var topY: CGFloat {
get { contentOffset.y }
}
var bottomY: CGFloat {
get { contentOffset.y + bounds.height }
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize.height == 0 {
setup()
}
let newScreenHeight = bounds.height
if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 {
contentOffset.y += oldScreenHeight - newScreenHeight
scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom)
}
oldScreenHeight = newScreenHeight
adaptItems(listState.items, false)
if let index = scrollToItemIndexDelayed {
scrollToItem(index)
scrollToItemIndexDelayed = nil
}
}
private func setup() {
contentSize = CGSizeMake(frame.size.width, initialOffset * 2)
prevProcessedOffset = initialOffset
contentOffset = CGPointMake(0, initialOffset)
showsVerticalScrollIndicator = false
scrollBarView.showsHorizontalScrollIndicator = false
panGestureRecognizer.delegate = self
addGestureRecognizer(scrollBarView.panGestureRecognizer)
superview!.addSubview(scrollBarView)
}
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
if !Thread.isMainThread {
fatalError("Use main thread to update items")
}
if bounds.height == 0 {
self.listState.items = items
// this function requires to have valid bounds and it will be called again once it has them
return
}
adaptItems(items, forceReloadVisible)
snapToContent(animated: false)
}
/// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes
private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) {
let start = Date.now
// special case when everything was removed
if items.isEmpty {
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
listState.visibleItems = []
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = 0
listState.firstVisibleItemOffset = -insetTop
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
scrollBarView.contentSize = .zero
scrollBarView.contentOffset = .zero
prevProcessedOffset = contentOffset.y
// this check is just to prevent didSet listener from firing on the same empty array, no use for this
if !self.listState.items.isEmpty {
self.listState.items = items
}
return
}
let contentOffsetY = overridenOffset ?? contentOffset.y
var oldVisible = listState.visibleItems
var newVisible: [VisibleItem] = []
let offsetsDiff = contentOffsetY - prevProcessedOffset
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset
var alreadyChangedIndexWhileScrolling = false
var allowOneMore = false
var nextOffsetY: CGFloat = 0
var i = shouldBeFirstVisible
// building list of visible items starting from the first one that should be visible
while i >= 0 && i < items.count {
let item = items[i]
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
let visible: VisibleItem?
if let visibleIndex {
let v = oldVisible.remove(at: visibleIndex)
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
updateCell(v.view, i, items)
}
visible = v
} else {
visible = nil
}
if shouldBeFirstVisible == i {
if let vis = visible {
if // there is auto scroll in progress and the first item has a higher offset than bottom part
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
// re-make the first visible item
(listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
// the fist visible item previously is hidden now, remove it and move on
!isVisible(vis.view) {
let newIndex: Int
if listState.isScrolling {
// skip many items to make the scrolling take less time
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
alreadyChangedIndexWhileScrolling = true
indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff
newIndex = max(0, min(items.count - 1, i + indexDiff))
// offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore
wasFirstVisibleItemOffset = 0
} else {
// don't skip multiple items if it's manual scrolling gesture
newIndex = i + (offsetsDiff <= 0 ? 1 : -1)
}
shouldBeFirstVisible = newIndex
i = newIndex
cellsToReuse.append(vis.view)
hideAndRemoveFromSuperviewIfNeeded(vis.view)
continue
}
}
let vis: VisibleItem
if let visible {
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
} else {
let cell = createCell(i, items, &cellsToReuse)!
cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
newVisible.append(vis)
nextOffsetY = vis.view.frame.origin.y
} else {
let vis: VisibleItem
if let visible {
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
nextOffsetY -= vis.view.frame.height
vis.view.frame.origin.y = nextOffsetY
} else {
let cell = createCell(i, items, &cellsToReuse)!
nextOffsetY -= cell.frame.height
cell.frame.origin.y = nextOffsetY
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
newVisible.append(vis)
}
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
break
} else if abs(nextOffsetY) < contentOffsetY {
allowOneMore = false
}
i += 1
}
if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 {
var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height
let index = firstVisible.index
for i in stride(from: index - 1, through: 0, by: -1) {
let item = items[i]
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
let vis: VisibleItem
if let visibleIndex {
let visible = oldVisible.remove(at: visibleIndex)
visible.view.frame.origin.y = offset
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
} else {
let cell = createCell(i, items, &cellsToReuse)!
cell.frame.origin.y = offset
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
offset += vis.view.frame.height
newVisible.insert(vis, at: 0)
if offset >= contentOffsetY + bounds.height {
break
}
}
}
// removing already unneeded visible items
oldVisible.forEach { vis in
cellsToReuse.append(vis.view)
hideAndRemoveFromSuperviewIfNeeded(vis.view)
}
let itemsCountChanged = listState.items.count != items.count
prevProcessedOffset = contentOffsetY
listState.visibleItems = newVisible
listState.items = items
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
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY)
scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height)
if debug {
println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))")
}
}
func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) {
listState.firstVisibleItemIndex = index
listState.firstVisibleItemId = id
listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset
}
func scrollToItem(_ index: Int, top: Bool = true) {
if index >= listState.items.count || listState.isScrolling {
return
}
if bounds.height == 0 {
scrollToItemIndexDelayed = index
return
}
listState.isScrolling = true
defer {
listState.isScrolling = false
}
// just a faster way to set top item as requested index
listState.firstVisibleItemIndex = index
listState.firstVisibleItemId = listState.items[index].id
listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom
scrollBarView.flashScrollIndicators()
adaptItems(listState.items, false)
var adjustedOffset = self.contentOffset.y
var i = 0
var upPrev = index > listState.firstVisibleItemIndex
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1
while true {
let up = index > listState.firstVisibleItemIndex
if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
upPrev = up
}
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
adjustedOffset += offsetToScroll
if let item = listState.visibleItems.first(where: { $0.index == index }) {
let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else {
max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
}
setContentOffset(CGPointMake(contentOffset.x, y), animated: false)
scrollBarView.flashScrollIndicators()
break
}
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
i += 1
}
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
}
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
if index >= listState.items.count || listState.isScrolling {
return
}
listState.isScrolling = true
defer {
listState.isScrolling = false
}
var adjustedOffset = self.contentOffset.y
var i = 0
var upPrev = index > listState.firstVisibleItemIndex
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1
while true {
let up = index > listState.firstVisibleItemIndex
if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
upPrev = up
}
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
//println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)")
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
adjustedOffset += offsetToScroll
if let item = listState.visibleItems.first(where: { $0.index == index }) {
let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else {
max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
}
setContentOffset(CGPointMake(contentOffset.x, y), animated: true)
scrollBarView.flashScrollIndicators()
break
}
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
// skipping unneded relayout if this offset is already processed
if prevProcessedOffset - contentOffset.y != 0 {
adaptItems(listState.items, false)
snapToContent(animated: false)
}
// let UI time to update to see the animated position change
await MainActor.run {}
i += 1
}
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
}
func scrollToBottom() {
scrollToItem(0, top: false)
}
func scrollToBottomAnimated() {
Task {
await scrollToItemAnimated(0, top: false)
}
}
func scroll(by: CGFloat, animated: Bool = true) {
setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated)
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if !listState.items.isEmpty {
scrollToBottomAnimated()
}
return false
}
private func snapToContent(animated: Bool) {
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
if topY < estimatedContentHeight.topOffsetY - topBlankSpace {
setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated)
} else if bottomY > estimatedContentHeight.bottomOffsetY {
setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated)
}
}
func offsetToBottom(_ view: UIView) -> CGFloat {
bottomY - (view.frame.origin.y + view.frame.height)
}
/// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of
/// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view
func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) {
if view.isHidden {
// already passed this function
return
}
(view as? ReusableView)?.prepareForReuse()
view.isHidden = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if view.isHidden { view.removeFromSuperview() }
}
}
/// Synchronizing both scrollViews
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
snapToContent(animated: true)
}
}
override var contentOffset: CGPoint {
get { super.contentOffset }
set {
var newOffset = newValue
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y {
if !isDecelerating {
newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8)
} else {
DispatchQueue.main.async {
self.setContentOffset(newValue, animated: false)
self.snapToContent(animated: true)
}
}
} else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y {
if !isDecelerating {
newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8)
} else {
DispatchQueue.main.async {
self.setContentOffset(newValue, animated: false)
self.snapToContent(animated: true)
}
}
}
super.contentOffset = newOffset
}
}
private func stopScrolling() {
let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) {
estimatedContentHeight.topOffsetY
} else {
estimatedContentHeight.bottomOffsetY - bounds.height
}
setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false)
}
func isVisible(_ view: UIView) -> Bool {
if view.superview == nil {
return false
}
return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height))
}
}
private func println(_ text: String) {
print("\(Date.now.timeIntervalSince1970): \(text)")
}
@@ -366,14 +366,8 @@ struct GroupMemberInfoView: View {
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
ItemsModel.shared.loadOpenChat("@\(contactId)") {
dismissAllSheets(animated: true)
}
}
}
@@ -398,7 +392,7 @@ struct GroupMemberInfoView: View {
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
ItemsModel.shared.loadOpenChat("@\(memberContact.id)") {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
@@ -1,371 +0,0 @@
//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
}
} else {
controller.update(items: items)
}
}
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: cellReuseId
)
} else {
tableView.register(
HostingCell<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.representer.content(item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
cell.backgroundColor = .clear
return cell
}
// 4. External state changes will require manual layout updates
NotificationCenter.default
.addObserver(
self,
selector: #selector(updateLayout),
name: notificationName,
object: nil
)
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
}
}
.store(in: &bag)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
CGPoint(
x: tableView.contentOffset.x,
y: tableView.contentOffset.y + tableView.bounds.height
),
animated: true
)
Task { representer.scrollState = .atDestination }
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination }
}
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
// Sets content offset on initial load
if itemCount == 0 {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
itemCount = items.count
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
}
func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs
} else {
nil
}
let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id
} else {
nil
}
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
}
return nil
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) {
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<Hosted: View>: UITableViewCell {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !contentView.subviews.contains(hostingController.view) {
contentView.addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: contentView.leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: contentView.trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: contentView.topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: contentView.bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
override func prepareForReuse() {
super.prepareForReuse()
hostingController.rootView = nil
}
}
}
typealias ListState = (
scrollOffset: Double,
topItemDate: Date?,
bottomItemId: ChatItem.ID?
)
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(ChatItem.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
}
@@ -0,0 +1,52 @@
//
// ScrollViewCells.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 27.01.2025.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
protocol ReusableView {
func prepareForReuse()
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController`
final class HostingCell<Hosted: View>: UIView, ReusableView {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !subviews.contains(hostingController.view) {
addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
func prepareForReuse() {
//super.prepareForReuse()
hostingController.rootView = nil
}
}
@@ -384,12 +384,10 @@ struct ChatPreviewView: View {
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -65,6 +65,7 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatItemsChangesListener.cleared()
m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
@@ -539,7 +539,46 @@ private let versionDescriptions: [VersionDescription] = [
description: "Delivered even when Apple drops them."
)),
]
)
),
VersionDescription(
version: "v6.3",
// post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"),
features: [
.feature(Description(
icon: "at",
title: "Mention members 👋",
description: "Get notified when mentioned."
)),
.feature(Description(
icon: "flag",
title: "Send private reports",
description: "Help admins moderating their groups."
)),
.feature(Description(
icon: "list.bullet",
title: "Organize chats into lists",
description: "Don't miss important messages."
)),
.feature(Description(
icon: nil,
title: "Better privacy and security",
description: nil,
subfeatures: [
("eye.slash", "Private media file names."),
("trash", "Set message expiration in chats.")
]
)),
.feature(Description(
icon: nil,
title: "Better groups performance",
description: nil,
subfeatures: [
("bolt", "Faster sending messages."),
("person.2.slash", "Faster deletion of groups.")
]
)),
]
),
]
private let lastVersion = versionDescriptions.last!.version
@@ -367,13 +367,13 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice)
ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob)
ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
+38 -22
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.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.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 */; };
@@ -203,9 +203,14 @@
8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; };
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; };
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; };
8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */; };
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; };
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; };
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; };
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; };
8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; };
8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */; };
8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */; };
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
@@ -230,7 +235,6 @@
CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; };
CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; };
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
@@ -537,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.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.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>"; };
@@ -561,8 +565,13 @@
8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = "<group>"; };
8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollHelpers.swift; sourceTree = "<group>"; };
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = "<group>"; };
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = "<group>"; };
8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = "<group>"; };
8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = "<group>"; };
8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndlessScrollView.swift; sourceTree = "<group>"; };
8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewCells.swift; sourceTree = "<group>"; };
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
@@ -586,7 +595,6 @@
CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX SE.entitlements"; sourceTree = "<group>"; };
CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = "<group>"; };
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
@@ -690,8 +698,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -747,6 +755,8 @@
5C5F4AC227A5E9AF00B51EF1 /* Chat */ = {
isa = PBXGroup;
children = (
8CC317452D4FEBA800292A20 /* ScrollViewCells.swift */,
8CC317432D4FEB9B00292A20 /* EndlessScrollView.swift */,
6440CA01288AEC770062C672 /* Group */,
5CE4407427ADB657007B033A /* ChatItem */,
5CEACCE527DE977C000BD591 /* ComposeMessage */,
@@ -757,11 +767,13 @@
5CE4407127ADB1D0007B033A /* Emoji.swift */,
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */,
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */,
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */,
8CAD466E2D15A8100078D18F /* ChatScrollHelpers.swift */,
);
path = Chat;
sourceTree = "<group>";
@@ -772,8 +784,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.4-4btgYWZNeX12ojCzRaHJ3W.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.0.5-8EACP2Rqr2fC85J6D8STdI.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1418,8 +1430,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */,
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */,
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
@@ -1437,6 +1449,7 @@
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
8C74C3EC2C1B92A900039E77 /* Theme.swift in Sources */,
@@ -1526,6 +1539,7 @@
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */,
8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */,
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */,
@@ -1537,6 +1551,7 @@
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */,
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */,
@@ -1576,6 +1591,7 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */,
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */,
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
@@ -1957,7 +1973,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2006,7 +2022,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2047,7 +2063,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2067,7 +2083,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2092,7 +2108,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2129,7 +2145,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2166,7 +2182,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2217,7 +2233,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2268,7 +2284,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2302,7 +2318,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 263;
CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -74,7 +74,6 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
+6 -5
View File
@@ -203,7 +203,7 @@ public func chatResponse(_ s: String) -> ChatResponse {
let jChats = jApiChats["chats"] as? NSArray {
let chats = jChats.map { jChat in
if let chatData = try? parseChatData(jChat) {
return chatData
return chatData.0
}
return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "")
}
@@ -213,8 +213,8 @@ public func chatResponse(_ s: String) -> ChatResponse {
if let jApiChat = jResp["apiChat"] as? NSDictionary,
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
let jChat = jApiChat["chat"] as? NSDictionary,
let chat = try? parseChatData(jChat) {
return .apiChat(user: user, chat: chat)
let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) {
return .apiChat(user: user, chat: chat, navInfo: navInfo)
}
} else if type == "chatCmdError" {
if let jError = jResp["chatCmdError"] as? NSDictionary {
@@ -247,10 +247,11 @@ private func errorJson(_ jDict: NSDictionary) -> String? {
}
}
func parseChatData(_ jChat: Any) throws -> ChatData {
func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
let jChatDict = jChat as! NSDictionary
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!)
let jChatItems = jChatDict["chatItems"] as! NSArray
let chatItems = jChatItems.map { jCI in
if let ci: ChatItem = try? decodeObject(jCI) {
@@ -262,7 +263,7 @@ func parseChatData(_ jChat: Any) throws -> ChatData {
json: serializeJSON(jCI, options: .prettyPrinted) ?? ""
)
}
return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats)
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
}
func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
+28 -12
View File
@@ -42,7 +42,7 @@ public enum ChatCommand {
case apiGetSettings(settings: AppSettings)
case apiGetChatTags(userId: Int64)
case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
@@ -212,7 +212,7 @@ public enum ChatCommand {
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
case let .apiSendMessages(type, id, live, ttl, composedMessages):
@@ -600,7 +600,7 @@ public enum ChatResponse: Decodable, Error {
case chatStopped
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?)
case chatTags(user: UserRef, userTags: [ChatTag])
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
@@ -958,7 +958,7 @@ public enum ChatResponse: Decodable, Error {
case .chatStopped: return noDetails
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))")
case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
@@ -1209,10 +1209,15 @@ struct NewUser: Encodable {
}
public enum ChatPagination {
public static let INITIAL_COUNT = 75
public static let PRELOAD_COUNT = 100
public static let UNTIL_PRELOAD_COUNT = 50
case last(count: Int)
case after(chatItemId: Int64, count: Int)
case before(chatItemId: Int64, count: Int)
case around(chatItemId: Int64, count: Int)
case initial(count: Int)
var cmdString: String {
switch self {
@@ -1220,6 +1225,7 @@ public enum ChatPagination {
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
case let .initial(count): return "initial=\(count)"
}
}
}
@@ -1227,7 +1233,7 @@ public enum ChatPagination {
public struct ChatTagData: Encodable {
public var emoji: String?
public var text: String
public init(emoji: String?, text: String) {
self.emoji = emoji
self.text = text
@@ -1483,7 +1489,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable {
}
set { `operator` = newValue }
}
public static var sampleData1 = UserOperatorServers(
operator: ServerOperator.sampleData1,
smpServers: [UserServer.sampleData.preset],
@@ -1794,7 +1800,7 @@ public struct NetCfg: Codable, Equatable {
rcvConcurrency: 8,
smpPingInterval: 1200_000_000
)
public var withProxyTimeouts: NetCfg {
var cfg = self
cfg.tcpConnectTimeout = NetCfg.proxyDefaults.tcpConnectTimeout
@@ -1804,7 +1810,7 @@ public struct NetCfg: Codable, Equatable {
cfg.smpPingInterval = NetCfg.proxyDefaults.smpPingInterval
return cfg
}
public var hasProxyTimeouts: Bool {
tcpConnectTimeout == NetCfg.proxyDefaults.tcpConnectTimeout &&
tcpTimeout == NetCfg.proxyDefaults.tcpTimeout &&
@@ -1937,7 +1943,7 @@ public struct NetworkProxy: Equatable, Codable {
public static var def: NetworkProxy {
NetworkProxy()
}
public var valid: Bool {
let hostOk = switch NWEndpoint.Host(host) {
case .ipv4: true
@@ -1948,7 +1954,7 @@ public struct NetworkProxy: Equatable, Codable {
port > 0 && port <= 65535 &&
NetworkProxy.validCredential(username) && NetworkProxy.validCredential(password)
}
public static func validCredential(_ s: String) -> Bool {
!s.contains(":") && !s.contains("@")
}
@@ -2048,6 +2054,16 @@ public struct ChatSettings: Codable, Hashable {
public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
}
public struct NavigationInfo: Decodable {
public var afterUnread: Int = 0
public var afterTotal: Int = 0
public init(afterUnread: Int = 0, afterTotal: Int = 0) {
self.afterUnread = afterUnread
self.afterTotal = afterTotal
}
}
public enum MsgFilter: String, Codable, Hashable {
case none
case all
@@ -2254,7 +2270,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
case .instant: "Instant"
}
}
public var icon: String {
switch self {
case .off: return "arrow.clockwise"
@@ -2769,7 +2785,7 @@ public struct AppSettings: Codable, Equatable {
public var uiThemes: [ThemeOverrides]? = nil
public var oneHandUI: Bool? = nil
public var chatBottomBar: Bool? = nil
public func prepareForExport() -> AppSettings {
var empty = AppSettings()
let def = AppSettings.defaults
@@ -2907,6 +2907,7 @@ object ChatController {
is CR.RemoteHostConnected -> {
// TODO needs to update it instead in sessions
chatModel.currentRemoteHost.value = r.remoteHost
ModalManager.start.closeModals()
switchUIRemoteHost(r.remoteHost.remoteHostId)
}
is CR.ContactDisabled -> {
@@ -40,11 +40,21 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
url
}
else -> {
val response = Jsoup.connect(url)
val connection = Jsoup.connect(url)
.ignoreContentType(true)
.timeout(10000)
.followRedirects(true)
.execute()
val response = if (url.lowercase().startsWith("https://x.com/")) {
// Apple sends request with special user-agent which handled differently by X.com.
// Different response that includes video poster from post
connection
.userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0")
.execute()
} else {
connection
.execute()
}
val doc = response.parse()
val ogTags = doc.select(OG_SELECT_QUERY)
title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
@@ -762,6 +762,45 @@ private val versionDescriptions: List<VersionDescription> = listOf(
descrId = MR.strings.v6_2_improved_chat_navigation_descr
),
),
),
VersionDescription(
version = "v6.3",
// post = "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html",
features = listOf(
VersionFeature.FeatureDescription(
icon = MR.images.ic_at,
titleId = MR.strings.v6_3_mentions,
descrId = MR.strings.v6_3_mentions_descr
),
VersionFeature.FeatureDescription(
icon = MR.images.ic_flag,
titleId = MR.strings.v6_3_reports,
descrId = MR.strings.v6_3_reports_descr
),
VersionFeature.FeatureDescription(
icon = MR.images.ic_menu,
titleId = MR.strings.v6_3_organize_chat_lists,
descrId = MR.strings.v6_3_organize_chat_lists_descr
),
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_3_better_privacy_and_security,
descrId = null,
subfeatures = listOf(
MR.images.ic_visibility_off to MR.strings.v6_3_private_media_file_names,
MR.images.ic_delete to MR.strings.v6_3_set_message_expiration_in_chats
)
),
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_3_better_groups_performance,
descrId = null,
subfeatures = listOf(
MR.images.ic_bolt to MR.strings.v6_3_faster_sending_messages,
MR.images.ic_group_off to MR.strings.v6_3_faster_deletion_of_groups
)
),
)
)
)
@@ -2282,6 +2282,18 @@
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
<string name="v6_2_business_chats">Business chats</string>
<string name="v6_2_business_chats_descr">Privacy for your customers.</string>
<string name="v6_3_mentions">Mention members 👋</string>
<string name="v6_3_mentions_descr">Get notified when mentioned.</string>
<string name="v6_3_reports">Send private reports</string>
<string name="v6_3_reports_descr">Help admins moderating their groups.</string>
<string name="v6_3_organize_chat_lists">Organize chats into lists</string>
<string name="v6_3_organize_chat_lists_descr">Don\'t miss important messages.</string>
<string name="v6_3_better_privacy_and_security">Better privacy and security</string>
<string name="v6_3_private_media_file_names">Private media file names.</string>
<string name="v6_3_set_message_expiration_in_chats">Set message expiration in chats.</string>
<string name="v6_3_better_groups_performance">Better groups performance</string>
<string name="v6_3_faster_sending_messages">Faster sending messages.</string>
<string name="v6_3_faster_deletion_of_groups">Faster deletion of groups.</string>
<string name="view_updated_conditions">View updated conditions</string>
<!-- CustomTimePicker -->
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22" fill="#000000"><path d="M480-85q-82.01 0-154.15-31.13-72.13-31.13-125.49-84.5Q147-254 116-326.02q-31-72.03-31-153.92 0-81.97 31.13-154.13 31.13-72.17 84.5-125.55Q254-813 326.02-844q72.03-31 153.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.9q31 72.11 31 154.09v51.78q0 54.97-38.92 92.75T741-297.5q-36.08 0-67.41-17.75Q642.25-333 625.5-364.5q-25.5 34-64.33 50.5-38.84 16.5-81.02 16.5-76.26 0-129.95-53-53.7-53-53.7-129.76 0-76.77 53.7-130.5Q403.89-664.5 480-664.5q76.1 0 129.8 53.76 53.7 53.76 53.7 130.58V-428q0 30.61 22.86 51.81Q709.22-355 740.5-355t54.14-21.19q22.86-21.2 22.86-51.81v-52q0-141.38-98.06-239.44T480-817.5q-141.37 0-239.44 98.06Q142.5-621.38 142.5-480q0 141.37 98.06 239.44Q338.63-142.5 480-142.5h184q11.67 0 20.09 8.46 8.41 8.47 8.41 20.22 0 12.32-8.41 20.57Q675.67-85 664-85H480Zm0-270q52.5 0 89.25-36.44t36.75-88.5q0-53.56-36.75-90.31T480-607q-52.5 0-89.25 36.69T354-480.06q0 52.06 36.75 88.56T480-355Z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22" fill="#000000"><path d="m811.5-67-192-191h57v33q0 24.39-17.05 41.45Q642.39-166.5 618-166.5H103q-24.44 0-40.97-16.77Q45.5-200.03 45.5-224v-33.6q0-34.57 17.46-62.33 17.46-27.75 49.32-41.53Q185-393 242.5-407t118.27-14q28.11 0 54.48 3 26.37 3 52.75 8l-74-73.5q-8.17 2-16.3 2.5t-16.73.5Q296-480.5 255-521.38T214-627.5q0-8.37.5-16.65t2.5-16.35l-164.5-165q-9-8.5-9-20.5t8.75-20.5Q61-875 73-875t20.6 8.59L853-107.5q8.5 8.67 8.5 20.23T853-66.85Q843.97-58 831.98-58 820-58 811.5-67Zm-189-355.5q68 8.5 126.8 22.9 58.79 14.39 96.2 34.1 33 17.5 51.25 45.5T915-257.12v90.62h-39L726-317q-13-36-39.75-62t-63.75-43.5Zm-261.37 59q-56.37 0-105.25 10.75T135-310q-14 6.5-23 21.5t-9 31v33.5h516v-34.5l-64.5-65q-58-23.5-100.75-31.75t-92.62-8.25ZM535-508q15.5-22.5 23.75-53.18 8.25-30.67 8.25-66.88 0-44.28-12.25-78.11T518.5-768q10-2.81 23-4.66 13-1.84 23.5-1.84 65.24 0 106.12 41.03Q712-692.45 712-627.81q0 64.84-42.6 105.82Q626.8-481 562-481l-27-27Zm-45-45-43.5-43.5q2-7 3-14.75t1-16.25q0-39.09-25.21-64.29Q400.09-717 361-717q-8.5 0-16.25 1T330-713l-43.5-43.5q15.5-8.75 34.5-13.38 19-4.62 40-4.62 65.24 0 106.12 40.88Q508-692.74 508-627.5q0 21-4.62 40-4.63 19-13.38 34.5ZM367-224Zm21.5-431Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.3-beta.4
android.version_code=273
android.version_name=6.3-beta.5
android.version_code=276
desktop.version_name=6.3-beta.4
desktop.version_code=91
desktop.version_name=6.3-beta.5
desktop.version_code=93
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
+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: c192339af9e9342902731f2d49ff380359be0dec
tag: f9d7b1eebc7e825423ee0d3b995a69c4006ac99c
source-repository-package
type: git
@@ -0,0 +1,97 @@
# Member sending limits
## Problem
Rate limiting member sending to prevent abuse in groups.
## Solution
Each member record to have `rateLimit :: Maybe MemberRateLimit`, Nothing by default. Nothing means default rate limit for regular members (`MRLWindow`), and no limit for owners/admins/moderators (`MRLNone`). Default rate limit is defined in configuration, e.g. limit of 15 messages in 60 second window, or could be defined in group profile.
Rate limit can be overridden per member by sending `XGrpMemRestrict` with updated `rateLimit` by member of same role or higher, similar to changing roles. `APIRateLimitMember` allows to enable or disable rate limiting for member, we could also provide more granular control in it (pass `WindowLimit`), but it seems unnecessary complex for UI.
```haskell
data MemberRateLimit
= MRLNone -- default for owners, admins, moderators
| MRLWindow WindowLimit
deriving (Eq, Show)
data WindowLimit = WindowLimit
{ window :: Int, -- seconds
limit :: Int
}
deriving (Eq, Show)
-- sent in XGrpMemRestrict
data MemberRestrictions = MemberRestrictions
{ restriction :: MemberRestrictionStatus,
rateLimit :: Maybe MemberRateLimit -- Nothing means use default
}
deriving (Eq, Show)
-- new api in ChatCommand
| APIRateLimitMember GroupId GroupMemberId
-- new response in ChatResponse
| CRMemberRateLimit {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
-- new field in GroupMember
data GroupMember = GroupMember {
...
rateLimit :: Maybe MemberRateLimit,
...
}
```
Rate limit overrides to be persisted on group member records.
```sql
ALTER TABLE group_members ADD COLUMN rate_limit TEXT; -- MemberRateLimit JSON encoded
```
Limits can be tracked inside fixed windows both for receiving and sending.
```haskell
data ChatController = ChatController {
...
memberLimits :: TMap (GroupId, MemberId) (TVar MemberRateLimitWindow),
ownLimits :: TMap GroupId (TVar MemberRateLimitWindow),
...
}
data MemberRateLimitWindow = MemberRateLimitWindow {
startedAt :: UTCTime,
windowLimit :: WindowLimit,
messages :: Int
}
```
Client to track limit for each writing member in state - `memberLimits`. If current window's interval has passed, checked against `startedAt` of `MemberRateLimitWindow`, reset `messages` counter.
Track own limits per group - `ownLimits`. When limit in group is reached, `CRGroupSendingLimited blocked = True` event is sent to UI to block sending in group. Unblock group sending in UI by scheduling background process to send `CRGroupSendingLimited blocked = False` after interval?
```haskell
-- new event in ChatResponse
| CRGroupSendingLimited {user :: User, groupInfo :: GroupInfo, blocked :: Bool}
```
### Receiving messages from limited members
Receiving message from member that exceeded their limit would fail it as prohibited. We can limit content messages, updates, reactions, etc. Practically only regular members would be limited, so there's no need for limiting service messages. Should we limit deletes?
Problems:
- Inconsistencies in window tracking on sending and receiving sides -> track based on item_ts?;
- Subscription produces message surges;
- Server downtime (or network outage) leads to accumulation of scheduled messages on sending side -> item_ts tracking wouldn't help. Issue is similar to subscription, where many messages can be received in short span legitimately.
- This approach doesn't reduce load of retrieving message and of all machinery passing from agent to chat.
Subscription issue could be solved by not tracking limits during subscription (client "knows" when it has subscribed) and for some time after it. For how long - 30 seconds / 1 minute? - arbitrary, longer absence periods lead to more not yet retrieved messages, freed quotas resulting in more sent pending messages.
Better solution would be to not drop (prohibit) messages at all, but stop reception per connection for periods of time. Possible approaches:
- Don't send (delay) ACK - bad idea as it would lead to repeated processing on client restart, and other possible failures in delivery.
- ACK with parameter `wait` for server - server would wait before sending next message.
- Unsubscribe (kill client?)/resubscribe after some time - more expensive.
- Signal agent to hold on next message - similar to delayed ACK but at least we don't process message. (for example, also via ACK but parameter to wait is for agent)
- Rework chat-to-agent communication to communicate via per connection queue (currently it's single `subQ`) - complex - but we get less expensive "unsubscribe"? Essentially agent still holds on to message like in previous approach.
@@ -38,6 +38,27 @@
</description>
<releases>
<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>
<p>New in v6.2.1-5:</p>
<ul>
<li>change media filenames when forwarding.</li>
<li>fully delete wallpapers when deleting user or chat.</li>
<li>important fixes</li>
<li>offer to "fix" encryption when calling or making direct connection with member.</li>
<li>broken layout.</li>
<li>option to enable debug logs (disabled by default).</li>
<li>show who reacted in direct chats.</li>
</ul>
<p>New in v6.2:</p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app to improve metadata privacy.</li>
<li>Business chats your customers privacy.</li>
<li>Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.</li>
</ul>
</description>
</release>
<release version="6.2.4" date="2025-01-14">
<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"."c192339af9e9342902731f2d49ff380359be0dec" = "1h81kmwllqza7wil7w20ia934b6iny3dch9fln0x95l9q3f4zww7";
"https://github.com/simplex-chat/simplexmq.git"."f9d7b1eebc7e825423ee0d3b995a69c4006ac99c" = "1yi4igwrgj5n0ljh06fhzzf2yprrwx9nrzp56s305z9g7mgx7gqm";
"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";
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.3.0.5
version: 6.3.0.6
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+5 -4
View File
@@ -54,7 +54,7 @@ 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
import Simplex.Messaging.Server.Env.STM (ServerConfig (..), StartOptions (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration)
import Simplex.Messaging.Server.MsgStore.Types (AMSType (..), SMSType (..))
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransportServerConfig)
@@ -283,13 +283,13 @@ insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) V
#endif
startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC
startTestChat_ TestParams {printOutput} db cfg opts user = do
startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user = do
t <- withVirtualTerminal termSettings pure
ct <- newChatTerminal t opts
cc <- newChatController db (Just user) cfg opts False
void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc
chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts
atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry
atomically . unless maintenance $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry
termQ <- newTQueueIO
termAsync <- async $ readTerminalOutput t termQ
pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput}
@@ -517,7 +517,8 @@ smpServerCfg =
smpAgentCfg = defaultSMPClientAgentConfig,
allowSMPProxy = True,
serverClientConcurrency = 16,
information = Nothing
information = Nothing,
startOptions = StartOptions False False
}
withSmpServer :: IO () -> IO ()