mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-07 11:02:45 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
@@ -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 -> {
|
||||
|
||||
+12
-2
@@ -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()
|
||||
|
||||
+39
@@ -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 |
@@ -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
@@ -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,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
@@ -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
@@ -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 ()
|
||||
|
||||
Reference in New Issue
Block a user