diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70f5d97e63..af841db613 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,12 @@ jobs: cache_path: C:/cabal asset_name: simplex-chat-windows-x86-64 desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' + run: exit 0 + - name: Configure pagefile (Windows) if: matrix.os == 'windows-latest' uses: al-cheb/configure-pagefile-action@v1.3 diff --git a/README.md b/README.md index ad70c350e4..936667da8c 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,14 @@ Your donations help us raise more funds - any amount, even the price of the cup It is possible to donate via: - [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). -- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad -- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt +- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u +- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg -- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- USDT: - - Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu +- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg +- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf +- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - please ask if you want to donate any other coins. Thank you, diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2784551361..95cebcde10 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -114,11 +114,11 @@ class ChatTagsModel: ObservableObject { var newUnreadTags: [Int64:Int] = [:] for chat in chats { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chat.chatInfo) { + if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) { newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1 } } - if chat.isUnread, let tags = chat.chatInfo.chatTags { + if chat.unreadTag, let tags = chat.chatInfo.chatTags { for tag in tags { newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1 } @@ -143,49 +143,58 @@ class ChatTagsModel: ObservableObject { } } - func addPresetChatTags(_ chatInfo: ChatInfo) { + func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chatInfo) { + if presetTagMatchesChat(tag, chatInfo, chatStats) { presetTags[tag] = (presetTags[tag] ?? 0) + 1 } } } - func removePresetChatTags(_ chatInfo: ChatInfo) { + func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chatInfo) { + if presetTagMatchesChat(tag, chatInfo, chatStats) { if let count = presetTags[tag] { - presetTags[tag] = max(0, count - 1) + if count > 1 { + presetTags[tag] = count - 1 + } else { + presetTags.removeValue(forKey: tag) + } } } } } func markChatTagRead(_ chat: Chat) -> Void { - if chat.isUnread, let tags = chat.chatInfo.chatTags { - markChatTagRead_(chat, tags) + if chat.unreadTag, let tags = chat.chatInfo.chatTags { + decTagsReadCount(tags) } } func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void { guard let tags = chat.chatInfo.chatTags else { return } - let nowUnread = chat.isUnread + let nowUnread = chat.unreadTag if nowUnread && !wasUnread { for tag in tags { unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 } } else if !nowUnread && wasUnread { - markChatTagRead_(chat, tags) + decTagsReadCount(tags) } } - private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void { + func decTagsReadCount(_ tags: [Int64]) -> Void { for tag in tags { if let count = unreadTags[tag] { unreadTags[tag] = max(0, count - 1) } } } + + func changeGroupReportsTag(_ by: Int = 0) { + if by == 0 { return } + presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by + } } class NetworkModel: ObservableObject { @@ -432,7 +441,7 @@ final class ChatModel: ObservableObject { updateChatInfo(cInfo) } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) - ChatTagsModel.shared.addPresetChatTags(cInfo) + ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats()) } } @@ -694,7 +703,7 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { - let wasUnread = chat.isUnread + let wasUnread = chat.unreadTag chat.chatStats.unreadCount -= markedCount ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) @@ -709,7 +718,7 @@ final class ChatModel: ObservableObject { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in - let wasUnread = chat.isUnread + let wasUnread = chat.unreadTag chat.chatStats.unreadChat = unreadChat ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } @@ -847,7 +856,7 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatIndex: Int, by count: Int) { - let wasUnread = chats[chatIndex].isUnread + let wasUnread = chats[chatIndex].unreadTag chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) @@ -873,6 +882,27 @@ final class ChatModel: ObservableObject { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } + func increaseGroupReportsCounter(_ chatId: ChatId) { + changeGroupReportsCounter(chatId, 1) + } + + func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) { + changeGroupReportsCounter(chatId, -1) + } + + private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) { + if by == 0 { return } + + if let i = getChatIndex(chatId) { + let chat = chats[i] + let wasReportsCount = chat.chatStats.reportsCount + chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by) + let nowReportsCount = chat.chatStats.reportsCount + let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0 + ChatTagsModel.shared.changeGroupReportsTag(by) + } + } + // this function analyses "connected" events and assumes that each member will be there only once func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { var count = 0 @@ -956,7 +986,8 @@ final class ChatModel: ObservableObject { withAnimation { if let i = getChatIndex(id) { let removed = chats.remove(at: i) - ChatTagsModel.shared.removePresetChatTags(removed.chatInfo) + ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats) + removeWallpaperFilesFromChat(removed) } } } @@ -995,6 +1026,23 @@ final class ChatModel: ObservableObject { _ = upsertGroupMember(groupInfo, updatedMember) } } + + func removeWallpaperFilesFromChat(_ chat: Chat) { + if case let .direct(contact) = chat.chatInfo { + removeWallpaperFilesFromTheme(contact.uiThemes) + } else if case let .group(groupInfo) = chat.chatInfo { + removeWallpaperFilesFromTheme(groupInfo.uiThemes) + } + } + + func removeWallpaperFilesFromAllChats(_ user: User) { + // Currently, only removing everything from currently active user is supported. Inactive users are TODO + if user.userId == currentUser?.userId { + chats.forEach { + removeWallpaperFilesFromChat($0) + } + } + } } struct ShowingInvitation { @@ -1055,8 +1103,8 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } } - var isUnread: Bool { - chatStats.unreadCount > 0 || chatStats.unreadChat + var unreadTag: Bool { + chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) } var id: ChatId { get { chatInfo.id } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7eb78edf74..2380f79d59 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,12 +15,6 @@ import SimpleXChat private var chatController: chat_ctrl? -// currentChatVersion in core -public let CURRENT_CHAT_VERSION: Int = 2 - -// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION) - private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") enum TerminalItem: Identifiable { @@ -346,7 +340,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { +func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async { do { let cInfo = chat.chatInfo let m = ChatModel.shared @@ -359,6 +353,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { 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))") @@ -460,6 +457,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage] return nil } +func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { + let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + + logger.error("apiReportMessage error: \(String(describing: r))") + AlertManager.shared.showAlertMsg( + title: "Error creating report", + message: "Error: \(responseError(r))" + ) + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( @@ -638,7 +647,13 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL { } private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { - if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + if case let .chatItemTTL(_, chatItemTTL) = r { + if let ttl = chatItemTTL { + return ChatItemTTL(ttl) + } else { + throw RuntimeError("chatItemTTLResponse: invalid ttl") + } + } throw r } @@ -647,6 +662,11 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } +func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws { + let userId = try currentUserId("setChatItemTTL") + try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value)) +} + func getNetworkConfig() async throws -> NetCfg? { let r = await chatSendCmd(.apiGetNetworkConfig) if case let .networkConfig(cfg) = r { return cfg } @@ -846,6 +866,18 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return (nil, alert) + case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): + let alert = Alert( + title: Text("Connection blocked"), + message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), + primaryButton: .default(Text("Ok")), + secondaryButton: .default(Text("How it works")) { + DispatchQueue.main.async { + UIApplication.shared.open(contentModerationPostLink) + } + } + ) + return (nil, alert) case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): let alert = mkAlert( title: "Undelivered messages", @@ -1026,6 +1058,12 @@ func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Co throw r } +func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? { + let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) + if case let .groupAliasUpdated(_, toGroup) = r { return toGroup } + throw r +} + func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } @@ -1986,6 +2024,9 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { if active(user) { m.addChatItem(cInfo, cItem) + if cItem.isActiveReport { + m.increaseGroupReportsCounter(cInfo.id) + } } else if cItem.isRcvNew && cInfo.ntfsEnabled { m.increaseUnreadCounter(user: user) } @@ -2049,6 +2090,40 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): + if !active(user) { + do { + let users = try listUsers() + await MainActor.run { + m.users = users + } + } catch { + logger.error("Error loading users: \(error)") + } + return + } + let im = ItemsModel.shared + let cInfo = ChatInfo.group(groupInfo: groupInfo) + await MainActor.run { + m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) + } + var notFound = chatItemIDs.count + for ci in im.reversedChatItems { + if chatItemIDs.contains(ci.id) { + let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { + CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) + } else { + CIDeleted.deleted(deletedTs: Date.now) + } + await MainActor.run { + var newItem = ci + newItem.meta.itemDeleted = deleted + _ = m.upsertChatItem(cInfo, newItem) + } + notFound -= 1 + if notFound == 0 { break } + } + } case let .receivedGroupInvitation(user, groupInfo, _, _): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 1c3203920a..7a5003c94d 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -109,6 +109,7 @@ struct ChatInfoView: View { @State private var showConnectContactViaAddressDialog = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { @@ -137,50 +138,48 @@ struct ChatInfoView: View { var body: some View { NavigationView { - List { - contactInfoHeader() - .listRowBackground(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - aliasTextFieldFocused = false - } - - Group { + ZStack { + List { + contactInfoHeader() + .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + aliasTextFieldFocused = false + } + localAliasTextEdit() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.bottom, 18) - - GeometryReader { g in - HStack(alignment: .center, spacing: 8) { - let buttonWidth = g.size.width / 4 - searchButton(width: buttonWidth) - AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) - } - } - .padding(.trailing) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) - - if let customUserProfile = customUserProfile { - Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { - HStack { - Text("Your random profile") - Spacer() - Text(customUserProfile.chatViewName) - .foregroundStyle(.indigo) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + GeometryReader { g in + HStack(alignment: .center, spacing: 8) { + let buttonWidth = g.size.width / 4 + searchButton(width: buttonWidth) + AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + muteButton(width: buttonWidth) } } - } - - Section { - Group { + .padding(.trailing) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) + + if let customUserProfile = customUserProfile { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(customUserProfile.chatViewName) + .foregroundStyle(.indigo) + } + } + } + + Section { if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() sendReceiptsOption() @@ -191,97 +190,109 @@ struct ChatInfoView: View { // } else if developerTools { // synchronizeConnectionButtonForce() // } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + // } else if developerTools { + // synchronizeConnectionButtonForce() + // } } .disabled(!contact.ready || !contact.active) - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - // } else if developerTools { - // synchronizeConnectionButtonForce() - // } - } - .disabled(!contact.ready || !contact.active) - - if let conn = contact.activeConn { + Section { - infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") - } - } - - if let contactLink = contact.contactLink { - Section { - SimpleXLinkQRCode(uri: contactLink) - Button { - showShareSheet(items: [simplexChatLink(contactLink)]) - } label: { - Label("Share address", systemImage: "square.and.arrow.up") - } - } header: { - Text("Address") - .foregroundColor(theme.colors.secondary) + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { - Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") - .foregroundColor(theme.colors.secondary) + Text("Delete chat messages from your device.") } - } - - if contact.ready && contact.active { - Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert + + if let conn = contact.activeConn { + Section { + infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") + } + } + + if let contactLink = contact.contactLink { + Section { + SimpleXLinkQRCode(uri: contactLink) + Button { + showShareSheet(items: [simplexChatLink(contactLink)]) + } label: { + Label("Share address", systemImage: "square.and.arrow.up") } - if let connStats = connectionStats { - Button("Change receiving address") { - alert = .switchAddressAlert - } - .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } - || connStats.ratchetSyncSendProhibited - ) - if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { - Button("Abort changing address") { - alert = .abortSwitchAddressAlert + } header: { + Text("Address") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") + .foregroundColor(theme.colors.secondary) + } + } + + if contact.ready && contact.active { + Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { + networkStatusRow() + .onTapGesture { + alert = .networkStatusAlert + } + if let connStats = connectionStats { + Button("Change receiving address") { + alert = .switchAddressAlert } .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { + Button("Abort changing address") { + alert = .abortSwitchAddressAlert + } + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + || connStats.ratchetSyncSendProhibited + ) + } + smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) + smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } - smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) - smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } } - } - - Section { - clearChatButton() - deleteContactButton() - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - Button ("Debug delivery") { - Task { - do { - let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) - await MainActor.run { alert = .queueInfo(info: info) } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } + + Section { + clearChatButton() + deleteContactButton() + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") + Button ("Debug delivery") { + Task { + do { + let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) + await MainActor.run { alert = .queueInfo(info: info) } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } + } } } } } } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) + } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { @@ -290,7 +301,6 @@ struct ChatInfoView: View { } sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - Task { do { let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) @@ -498,7 +508,7 @@ struct ChatInfoView: View { chatSettings.sendRcpts = sendReceipts.bool() updateChatSettings(chat, chatSettings: chatSettings) } - + private func synchronizeConnectionButton() -> some View { Button { Task { @@ -643,6 +653,63 @@ struct ChatInfoView: View { } } +struct ChatTTLOption: View { + @ObservedObject var chat: Chat + @Binding var progressIndicator: Bool + @State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0)) + @State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0)) + + var body: some View { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl)) + } + let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL) + Text(defaultTTL.text).tag(defaultTTL) + + if case .chat(let ttl) = chatItemTTL, case .seconds = ttl { + Text(ttl.deleteAfterText).tag(chatItemTTL) + } + } + .disabled(progressIndicator) + .frame(height: 36) + .onChange(of: chatItemTTL) { ttl in + if ttl == currentChatItemTTL { return } + setChatTTL( + ttl, + hasPreviousTTL: !currentChatItemTTL.neverExpires, + onCancel: { chatItemTTL = currentChatItemTTL } + ) { + progressIndicator = true + Task { + do { + try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + progressIndicator = false + currentChatItemTTL = chatItemTTL + } + } + catch let error { + logger.error("setChatTTL error \(responseError(error))") + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + chatItemTTL = currentChatItemTTL + progressIndicator = false + } + } + } + } + } + .onAppear { + let sm = ChatModel.shared + let ttl = chat.chatInfo.ttl(sm.chatItemTTL) + chatItemTTL = ttl + currentChatItemTTL = ttl + } + } +} + func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? { do { let stats = try apiSyncContactRatchet(contact.apiId, force) @@ -1054,6 +1121,33 @@ func deleteContactDialog( } } +func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) { + let title = if ttl.neverExpires { + NSLocalizedString("Disable automatic message deletion?", comment: "alert title") + } else if ttl.usingDefault || hasPreviousTTL { + NSLocalizedString("Change automatic message deletion?", comment: "alert title") + } else { + NSLocalizedString("Enable automatic message deletion?", comment: "alert title") + } + + let message = if ttl.neverExpires { + NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message") + } else { + NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message") + } + + showAlert(title, message: message) { + [ + UIAlertAction( + title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"), + style: .destructive, + handler: { _ in onConfirm() } + ), + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() }) + ] + } +} + private func deleteContactOrConversationDialog( _ chat: Chat, _ contact: Contact, @@ -1254,7 +1348,7 @@ struct ChatInfoView_Previews: PreviewProvider { localAlias: "", featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), - onSearch: {} + onSearch: {} ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index f5ab7f3a4b..a785f3e6d8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -118,16 +118,10 @@ struct CIFileView: View { } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): logger.debug("CIFileView fileAction - in .rcvWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { @@ -140,16 +134,10 @@ struct CIFileView: View { } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): logger.debug("CIFileView fileAction - in .sndWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: break } } @@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) { } } +func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { + let title: String = if temporary { + NSLocalizedString("Temporary file error", comment: "file error alert title") + } else { + NSLocalizedString("File error", comment: "file error alert title") + } + if let btn = err.moreInfoButton { + showAlert(title, message: err.errorInfo) { + [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + ] + } + } else { + showAlert(title, message: err.errorInfo) + } +} + struct CIFileView_Previews: PreviewProvider { static var previews: some View { let sentFile: ChatItem = ChatItem( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b06c6df48c..d491563913 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -69,25 +69,13 @@ struct CIImageView: View { case .rcvComplete: () // ? case .rcvCancelled: () // TODO case let .rcvError(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case let .sndError(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: () } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 851b90bc3d..f774299ad3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -355,18 +355,12 @@ struct CIVideoView: View { case let .sndError(sndFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) @@ -382,18 +376,12 @@ struct CIVideoView: View { case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: fileIcon("questionmark", 10, 13) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index acecaaae4f..ff4378c715 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -169,18 +169,12 @@ struct VoiceMessagePlayer: View { case let .sndError(sndFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() @@ -191,18 +185,12 @@ struct VoiceMessagePlayer: View { case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b71e6c4a4..6da893d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -30,7 +30,17 @@ struct FramedItemView: View { var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { - if let di = chatItem.meta.itemDeleted { + if chatItem.isReport { + if chatItem.meta.itemDeleted == nil { + let txt = chatItem.chatDir.sent ? + Text("Only you and moderators see it") : + Text("Only sender and moderators see it") + + framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic()) + } else { + framedItemHeader(icon: "flag", caption: Text("archived report").italic()) + } + } else if let di = chatItem.meta.itemDeleted { switch di { case let .moderated(_, byGroupMember): framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) @@ -144,6 +154,8 @@ struct FramedItemView: View { } case let .file(text): ciFileView(chatItem, text) + case let .report(text, reason): + ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -159,13 +171,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) + .foregroundColor(iconColor ?? theme.colors.secondary) } caption .font(.caption) @@ -228,7 +241,6 @@ struct FramedItemView: View { .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, theme)) - if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -281,7 +293,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -291,7 +303,8 @@ struct FramedItemView: View { formattedText: ft, meta: ci.meta, rightToLeft: rtl, - showSecrets: showSecrets + showSecrets: showSecrets, + prefix: txtPrefix )) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c2b4021edc..87a9b2ce61 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View { // same texts are in markedDeletedText in ChatPreviewView, but it returns String; // can be refactored into a single function if functions calling these are changed to return same type var markedDeletedText: LocalizedStringKey { - switch chatItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" - case .blocked: "blocked" - case .blockedByAdmin: "blocked by admin" - case .deleted, nil: "marked deleted" + if chatItem.meta.itemDeleted != nil, chatItem.isReport { + "archived report" + } else { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + case .blockedByAdmin: "blocked by admin" + case .deleted, nil: "marked deleted" + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 914f7c8a2f..e9b6d0ba84 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -34,6 +34,7 @@ struct MsgContentView: View { var meta: CIMeta? = nil var rightToLeft = false var showSecrets: Bool + var prefix: Text? = nil @State private var typingIdx = 0 @State private var timer: Timer? @@ -67,7 +68,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -89,9 +90,10 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text + if let ft = formattedText, ft.count > 0 && ft.count <= 200 { res = formatText(ft[0], preview, showSecret: showSecrets) var i = 1 @@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let i = icon { res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res } + + if let p = prefix { + res = p + res + } if let s = sender { let t = Text(s) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ac4066d23e..baceb5b4ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -253,7 +253,8 @@ struct ChatView: View { chat.created = Date.now } ), - onSearch: { focusSearch() } + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias ) } } else if case .local = cInfo { @@ -917,6 +918,7 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var actionSheet: SomeActionSheet? = nil var revealed: Bool { chatItem == revealedChatItem } @@ -1001,6 +1003,7 @@ struct ChatView: View { } } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { @@ -1208,7 +1211,7 @@ struct ChatView: View { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal, moderate: false) } - if let di = deletingItem, di.meta.deletable && !di.localNote { + if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { deleteMessage(.cidmBroadcast, moderate: false) } @@ -1282,7 +1285,12 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { + if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { + archiveReportButton(ci) + } + deleteButton(ci, label: "Delete report") + } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { reactionsGroup @@ -1332,8 +1340,12 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { - moderateButton(ci, groupInfo) + if ci.chatDir != .groupSnd { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + moderateButton(ci, groupInfo) + } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + // reportButton(ci) + // } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1607,7 +1619,7 @@ struct ChatView: View { } } - private func deleteButton(_ ci: ChatItem) -> Button { + private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, let currIndex = m.getChatItemIndex(ci), @@ -1629,10 +1641,7 @@ struct ChatView: View { deletingItem = ci } } label: { - Label( - NSLocalizedString("Delete", comment: "chat item action"), - systemImage: "trash" - ) + Label(label, systemImage: "trash") } } @@ -1651,10 +1660,10 @@ struct ChatView: View { AlertManager.shared.showAlert(Alert( title: Text("Delete member message?"), message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), primaryButton: .destructive(Text("Delete")) { deletingItem = ci deleteMessage(.cidmBroadcast, moderate: true) @@ -1668,6 +1677,24 @@ struct ChatView: View { ) } } + + private func archiveReportButton(_ cItem: ChatItem) -> Button { + Button(role: .destructive) { + AlertManager.shared.showAlert( + Alert( + title: Text("Archive report?"), + message: Text("The report will be archived for you."), + primaryButton: .destructive(Text("Archive")) { + deletingItem = cItem + deleteMessage(.cidmInternalMark, moderate: false) + }, + secondaryButton: .cancel() + ) + ) + } label: { + Label("Archive report", systemImage: "archivebox") + } + } private func revealButton(_ ci: ChatItem) -> Button { Button { @@ -1707,7 +1734,38 @@ struct ChatView: View { ) } } - + + private func reportButton(_ ci: ChatItem) -> Button { + Button(role: .destructive) { + var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in + .default(Text(reason.text)) { + withAnimation { + if composeState.editing { + composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } else { + composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } + } + } + } + + buttons.append(.cancel()) + + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Report reason?"), + buttons: buttons + ), + id: "reportChatMessage" + ) + } label: { + Label ( + NSLocalizedString("Report", comment: "chat item action"), + systemImage: "flag" + ) + } + } + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -1768,11 +1826,15 @@ struct ChatView: View { } else { m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem) } + let deletedItem = itemDeletion.deletedChatItem.chatItem + if deletedItem.isActiveReport { + m.decreaseGroupReportsCounter(chat.chatInfo.id) + } } } } } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + logger.error("ChatView.deleteMessage error: \(error)") } } } @@ -1845,6 +1907,10 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } else { ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) } + let deletedItem = di.deletedChatItem.chatItem + if deletedItem.isActiveReport { + ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) + } } } await onSuccess() @@ -2009,6 +2075,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { await MainActor.run { let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite) + let wasUnread = chat.unreadTag switch chat.chatInfo { case var .direct(contact): contact.chatSettings = chatSettings @@ -2018,6 +2085,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { ChatModel.shared.updateGroup(groupInfo) default: () } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } } catch let error { logger.error("apiSetChatSettings error \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 19e2b528f1..a68a4987a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -24,6 +24,7 @@ enum ComposeContextItem { case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) + case reportedItem(chatItem: ChatItem, reason: ReportReason) } enum VoiceMessageRecordingState { @@ -116,13 +117,31 @@ struct ComposeState { default: return false } } - + + var reporting: Bool { + switch contextItem { + case .reportedItem: return true + default: return false + } + } + + var submittingValidReport: Bool { + switch contextItem { + case let .reportedItem(_, reason): + switch reason { + case .other: return !message.isEmpty + default: return true + } + default: return false + } + } + var sendEnabled: Bool { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty || forwarding || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport } } @@ -175,7 +194,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || forwarding || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress || reporting { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -193,6 +212,15 @@ struct ComposeState { } } + var placeholder: String? { + switch contextItem { + case let .reportedItem(_, reason): + return reason.text + default: + return nil + } + } + var empty: Bool { message == "" && noPreview } @@ -297,6 +325,11 @@ struct ComposeView: View { ContextInvitingContactMemberView() Divider() } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } // preference checks should match checks in forwarding list let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) @@ -686,6 +719,27 @@ struct ComposeView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial) } + + + private func reportReasonView(_ reason: ReportReason) -> some View { + let reportText = switch reason { + case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason") + case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason") + case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason") + case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") + case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason") + case .unknown: "" // Should never happen + } + + return Text(reportText) + .italic() + .font(.caption) + .padding(12) + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { @@ -715,6 +769,15 @@ struct ComposeView: View { cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) Divider() + case let .reportedItem(chatItem: reportedItem, _): + ContextItemView( + chat: chat, + contextItems: [reportedItem], + contextIcon: "flag", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + contextIconForeground: Color.red + ) + Divider() } } @@ -746,6 +809,8 @@ struct ComposeView: View { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) + } else if case let .reportedItem(chatItem, reason) = composeState.contextItem { + sent = await send(reason, chatItemId: chatItem.id) } else { var quoted: Int64? = nil if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { @@ -872,6 +937,8 @@ struct ComposeView: View { return .voice(text: msgText, duration: duration) case .file: return .file(msgText) + case .report(_, let reason): + return .report(text: msgText, reason: reason) case .unknown(let type, _): return .unknown(type: type, text: msgText) } @@ -891,7 +958,25 @@ struct ComposeView: View { return nil } } - + + func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? { + if let chatItems = await apiReportMessage( + groupId: chat.chatInfo.apiId, + chatItemId: chatItemId, + reportReason: reportReason, + reportText: msgText + ) { + await MainActor.run { + for chatItem in chatItems { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + return chatItems.first + } + + return nil + } + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index fa999961fc..3cb747ec68 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -15,6 +15,7 @@ struct ContextItemView: View { let contextItems: [ChatItem] let contextIcon: String let cancelContextItem: () -> Void + var contextIconForeground: Color? = nil var showSender: Bool = true var body: some View { @@ -23,7 +24,7 @@ struct ContextItemView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) - .foregroundColor(theme.colors.secondary) + .foregroundColor(contextIconForeground ?? theme.colors.secondary) if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = singleItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { @@ -93,6 +94,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index ad47b7351a..2fc122f249 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var placeholder: String? let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 @@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + field.setPlaceholderView() updateFont(field) updateHeight(field) return field @@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable { updateFont(field) updateHeight(field) } + + let castedField = field as! CustomUITextField + if castedField.placeholder != placeholder { + castedField.placeholder = placeholder + } } private func updateHeight(_ field: UITextView) { @@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate { var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + private let placeholderLabel: UILabel = UILabel() + init(height: Binding) { self.height = height super.init(frame: .zero, textContainer: nil) } + var placeholder: String? { + get { placeholderLabel.text } + set { placeholderLabel.text = newValue } + } + required init?(coder: NSCoder) { fatalError("Not implemented") } @@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } + + func setPlaceholderView() { + placeholderLabel.textColor = .lightGray + placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body) + placeholderLabel.isHidden = !text.isEmpty + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), + placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8) + ]) + } func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged @@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { + placeholderLabel.isHidden = !text.isEmpty if textView.markedTextRange == nil { var images: [UploadContent] = [] var rangeDiff = 0 @@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + placeholder: Binding.constant("Placeholder"), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8880023e02..fb69dfdd17 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -61,6 +61,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -105,6 +106,8 @@ struct SendMessageView: View { let vmrs = composeState.voiceMessageRecordingState if nextSendGrpInv { inviteMemberContactButton() + } else if case .reportedItem = composeState.contextItem { + sendMessageButton() } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index bdef8d0a62..66fe67a29e 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { - ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole && role != .author { - Text(role.text) - } + ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in + Text(role.text) } } .frame(height: 36) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c4df91bb8b..b0f896e493 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -18,6 +18,8 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo var onSearch: () -> Void + @State var localAlias: String + @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member @@ -27,6 +29,7 @@ struct GroupChatInfoView: View { @State private var connectionCode: String? @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -67,101 +70,120 @@ struct GroupChatInfoView: View { .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } - List { - groupInfoHeader() - .listRowBackground(Color.clear) - .padding(.bottom, 18) - - infoActionButtons() - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section { - if groupInfo.isOwner && groupInfo.businessChat == nil { - editGroupButton() - } - if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { - addOrEditWelcomeMessage() - } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - } header: { - Text("") - } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) - } - - Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { - if groupInfo.canAddMembers { - if groupInfo.businessChat == nil { - groupLinkButton() + ZStack { + List { + groupInfoHeader() + .listRowBackground(Color.clear) + + localAliasTextEdit() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + infoActionButtons() + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + Section { + if groupInfo.isOwner && groupInfo.businessChat == nil { + editGroupButton() } - if (chat.chatInfo.incognito) { - Label("Invite members", systemImage: "plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { alert = .cantInviteIncognitoAlert } + if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { + addOrEditWelcomeMessage() + } + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() } else { - addMembersButton() + sendReceiptsOptionDisabled() } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + } header: { + Text("") + } footer: { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) + + Section { + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) + } footer: { + Text("Delete chat messages from your device.") } - let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } - MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) - ForEach(filteredMembers) { member in - ZStack { - NavigationLink { - memberInfoView(member) - } label: { - EmptyView() + + Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { + if groupInfo.canAddMembers { + if groupInfo.businessChat == nil { + groupLinkButton() } - .opacity(0) - MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } + } + if members.count > 8 { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + } + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + ForEach(filteredMembers) { member in + ZStack { + NavigationLink { + memberInfoView(member) + } label: { + EmptyView() + } + .opacity(0) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + } + } + } + + Section { + clearChatButton() + if groupInfo.canDelete { + deleteGroupButton() + } + if groupInfo.membership.memberCurrent { + leaveGroupButton() + } + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") } } } - - Section { - clearChatButton() - if groupInfo.canDelete { - deleteGroupButton() - } - if groupInfo.membership.memberCurrent { - leaveGroupButton() - } - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in @@ -200,7 +222,7 @@ struct GroupChatInfoView: View { ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) .padding(.top, 12) .padding() - Text(cInfo.displayName) + Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName) .font(.largeTitle) .multilineTextAlignment(.center) .lineLimit(4) @@ -215,6 +237,37 @@ struct GroupChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + private func localAliasTextEdit() -> some View { + TextField("Set chat name…", text: $localAlias) + .disableAutocorrection(true) + .focused($aliasTextFieldFocused) + .submitLabel(.done) + .onChange(of: aliasTextFieldFocused) { focused in + if !focused { + setGroupAlias() + } + } + .onSubmit { + setGroupAlias() + } + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondary) + } + + private func setGroupAlias() { + Task { + do { + if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } catch { + logger.error("setGroupAlias error: \(responseError(error))") + } + } + } + func infoActionButtons() -> some View { GeometryReader { g in let buttonWidth = g.size.width / 4 @@ -739,7 +792,8 @@ struct GroupChatInfoView_Previews: PreviewProvider { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), - onSearch: {} + onSearch: {}, + localAlias: "" ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 30972f7242..102f0333be 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -296,7 +296,7 @@ struct GroupMemberInfoView: View { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId, width: buttonWidth) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { createMemberContactButton(member, width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 7b185d8211..81498ee497 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -116,10 +116,10 @@ struct SelectedItemsBottomToolbar: View { if selected.contains(ci.id) { var (de, dee, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf - dee = dee && ci.meta.deletable && !ci.localNote - onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd - me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil - fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy + dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport + me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport + fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 6bf86840a8..f1ee4e4c42 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -8,7 +8,6 @@ import SwiftUI import SimpleXChat -import ElegantEmojiPicker typealias DynamicSizes = ( rowHeight: CGFloat, @@ -343,9 +342,9 @@ struct ChatListNavLink: View { AnyView( NavigationView { if chatTagsModel.userTags.isEmpty { - ChatListTagEditor(chat: chat) + TagListEditor(chat: chat) } else { - ChatListTag(chat: chat) + TagListView(chat: chat) } } ) @@ -560,389 +559,6 @@ struct ChatListNavLink: View { } } -struct TagEditorNavParams { - let chat: Chat? - let chatListTag: ChatTagData? - let tagId: Int64? -} - -struct ChatListTag: View { - var chat: Chat? = nil - var showEditButton: Bool = false - @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject var theme: AppTheme - @EnvironmentObject var chatTagsModel: ChatTagsModel - @EnvironmentObject var m: ChatModel - @State private var editMode = EditMode.inactive - @State private var tagEditorNavParams: TagEditorNavParams? = nil - - var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] } - - var body: some View { - List { - Section { - ForEach(chatTagsModel.userTags, id: \.id) { tag in - let text = tag.chatTagText - let emoji = tag.chatTagEmoji - let tagId = tag.chatTagId - let selected = chatTagsIds.contains(tagId) - - HStack { - if let emoji { - Text(emoji) - } else { - Image(systemName: "tag") - } - Text(text) - .padding(.leading, 12) - Spacer() - if chat != nil { - radioButton(selected: selected) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if let c = chat { - setTag(tagId: selected ? nil : tagId, chat: c) - } else { - tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { - showAlert( - NSLocalizedString("Delete list?", comment: "alert title"), - message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "alert action"), - style: .default - ), - UIAlertAction( - title: NSLocalizedString("Delete", comment: "alert action"), - style: .destructive, - handler: { _ in - deleteTag(tagId) - } - ) - ]} - ) - } label: { - Label("Delete", systemImage: "trash.fill") - } - .tint(.red) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(theme.colors.primary) - } - .background( - // isActive required to navigate to edit view from any possible tag edited in swipe action - NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) { - if let params = tagEditorNavParams { - ChatListTagEditor( - chat: params.chat, - tagId: params.tagId, - emoji: params.chatListTag?.emoji, - name: params.chatListTag?.text ?? "" - ) - } - } label: { - EmptyView() - } - .opacity(0) - ) - } - .onMove(perform: moveItem) - - NavigationLink { - ChatListTagEditor(chat: chat) - } label: { - Label("Create list", systemImage: "plus") - } - } header: { - if showEditButton { - editTagsButton() - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .modifier(ThemedBackground(grouped: true)) - .environment(\.editMode, $editMode) - } - - private func editTagsButton() -> some View { - if editMode.isEditing { - Button("Done") { - editMode = .inactive - dismiss() - } - } else { - Button("Edit") { - editMode = .active - } - } - } - - @ViewBuilder private func radioButton(selected: Bool) -> some View { - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .imageScale(.large) - .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) - } - - private func moveItem(from source: IndexSet, to destination: Int) { - Task { - do { - var tags = chatTagsModel.userTags - tags.move(fromOffsets: source, toOffset: destination) - try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId }) - - await MainActor.run { - chatTagsModel.userTags = tags - } - } catch let error { - showAlert( - NSLocalizedString("Error reordering lists", comment: "alert title"), - message: responseError(error) - ) - } - } - } - - private func setTag(tagId: Int64?, chat: Chat) { - Task { - do { - let tagIds: [Int64] = if let t = tagId { [t] } else {[]} - let (userTags, chatTags) = try await apiSetChatTags( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - tagIds: tagIds - ) - - await MainActor.run { - chatTagsModel.userTags = userTags - if var contact = chat.chatInfo.contact { - contact.chatTags = chatTags - m.updateContact(contact) - } else if var group = chat.chatInfo.groupInfo { - group.chatTags = chatTags - m.updateGroup(group) - } - dismiss() - } - } catch let error { - showAlert( - NSLocalizedString("Error saving chat list", comment: "alert title"), - message: responseError(error) - ) - } - } - } - - private func deleteTag(_ tagId: Int64) { - Task { - try await apiDeleteChatTag(tagId: tagId) - - await MainActor.run { - chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId } - if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId { - chatTagsModel.activeFilter = nil - } - m.chats.forEach { c in - if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) { - contact.chatTags = contact.chatTags.filter({ $0 != tagId }) - m.updateContact(contact) - } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) { - group.chatTags = group.chatTags.filter({ $0 != tagId }) - m.updateGroup(group) - } - } - } - } - } -} - -struct EmojiPickerView: UIViewControllerRepresentable { - @Binding var selectedEmoji: String? - @Binding var showingPicker: Bool - @Environment(\.presentationMode) var presentationMode - - class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate { - var parent: EmojiPickerView - - init(parent: EmojiPickerView) { - self.parent = parent - } - - func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) { - parent.selectedEmoji = emoji?.emoji - parent.showingPicker = false - picker.dismiss(animated: true) - } - - // Called when the picker is dismissed manually (without selection) - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - parent.showingPicker = false - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(parent: self) - } - - func makeUIViewController(context: Context) -> UIViewController { - let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false) - let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config) - - picker.presentationController?.delegate = context.coordinator - - let viewController = UIViewController() - DispatchQueue.main.async { - if let topVC = getTopViewController() { - topVC.present(picker, animated: true) - } - } - - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - // No need to update the controller after creation - } -} - -struct ChatListTagEditor: View { - var chat: Chat? = nil - var tagId: Int64? = nil - @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject var chatTagsModel: ChatTagsModel - @EnvironmentObject var theme: AppTheme - var emoji: String? - var name: String = "" - @State private var newEmoji: String? - @State private var newName: String = "" - @State private var isPickerPresented = false - @State private var saving: Bool? - - var body: some View { - VStack { - List { - let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in - tag.chatTagId != tagId && - ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName) - } - - Section { - HStack { - Button { - isPickerPresented = true - } label: { - if let newEmoji { - Text(newEmoji) - } else { - Image(systemName: "face.smiling") - .foregroundColor(.secondary) - } - } - TextField("List name...", text: $newName) - } - - Button { - saving = true - if let tId = tagId { - updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName)) - } else { - createChatTag() - } - } label: { - Text(NSLocalizedString(tagId == nil ? "Create list" : "Save list", comment: "list editor button")) - } - .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) - } footer: { - if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering - HStack { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - Text("List name and emoji should be different for all lists.") - .foregroundColor(theme.colors.secondary) - } - } - } - } - - if isPickerPresented { - EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented) - } - } - .modifier(ThemedBackground(grouped: true)) - .onAppear { - newEmoji = emoji - newName = name - } - } - - var trimmedName: String { - newName.trimmingCharacters(in: .whitespaces) - } - - private func createChatTag() { - Task { - do { - let userTags = try await apiCreateChatTag( - tag: ChatTagData(emoji: newEmoji , text: trimmedName) - ) - await MainActor.run { - saving = false - chatTagsModel.userTags = userTags - dismiss() - } - } catch let error { - await MainActor.run { - saving = nil - showAlert( - NSLocalizedString("Error creating list", comment: "alert title"), - message: responseError(error) - ) - } - } - } - } - - private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) { - Task { - do { - try await apiUpdateChatTag(tagId: tagId, tag: chatTagData) - await MainActor.run { - saving = false - for i in 0.. Alert { Alert( title: Text("Reject contact request"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9cb87a4b22..68e0c57c75 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -32,12 +32,18 @@ enum UserPickerSheet: Identifiable { } enum PresetTag: Int, Identifiable, CaseIterable, Equatable { - case favorites = 0 - case contacts = 1 - case groups = 2 - case business = 3 - + case groupReports = 0 + case favorites = 1 + case contacts = 2 + case groups = 3 + case business = 4 + case notes = 5 + var id: Int { rawValue } + + var сollapse: Bool { + self != .groupReports + } } enum ActiveFilter: Identifiable, Equatable { @@ -472,7 +478,7 @@ struct ChatListView: View { func filtered(_ chat: Chat) -> Bool { switch chatTagsModel.activeFilter { - case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo) + case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 case .none: true @@ -563,7 +569,7 @@ struct ChatListSearchBar: View { var body: some View { VStack(spacing: 12) { - ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet) } + ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") @@ -621,6 +627,9 @@ struct ChatListSearchBar: View { } } } + .onChange(of: chatTagsModel.activeFilter) { _ in + searchText = "" + } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" }) } @@ -662,11 +671,12 @@ struct ChatListSearchBar: View { } } -struct ChatTagsView: View { +struct TagsView: View { @EnvironmentObject var chatTagsModel: ChatTagsModel @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var parentSheet: SomeSheet? + @Binding var searchText: String var body: some View { HStack { @@ -680,6 +690,11 @@ struct ChatTagsView: View { expandedPresetTagsFiltersView() } else { collapsedTagsFilterView() + ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in + if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 { + expandedTagFilterView(tag) + } + } } } let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter { @@ -717,7 +732,7 @@ struct ChatTagsView: View { content: { AnyView( NavigationView { - ChatListTag(chat: nil, showEditButton: true) + TagListView(chat: nil) .modifier(ThemedBackground(grouped: true)) } ) @@ -734,7 +749,7 @@ struct ChatTagsView: View { content: { AnyView( NavigationView { - ChatListTagEditor() + TagListEditor() } ) }, @@ -752,30 +767,34 @@ struct ChatTagsView: View { } .foregroundColor(.secondary) } - - @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { + + @ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View { let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter { tag } else { nil } + let active = tag == selectedPresetTag + let (icon, text) = presetTagLabel(tag: tag, active: active) + let color: Color = active ? .accentColor : .secondary + + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + ZStack { + Text(text).fontWeight(.semibold).foregroundColor(.clear) + Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) + } + } + .onTapGesture { + setActiveFilter(filter: .presetTag(tag)) + } + } + + @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { ForEach(PresetTag.allCases, id: \.id) { tag in if (chatTagsModel.presetTags[tag] ?? 0) > 0 { - let active = tag == selectedPresetTag - let (icon, text) = presetTagLabel(tag: tag, active: active) - let color: Color = active ? .accentColor : .secondary - - HStack(spacing: 4) { - Image(systemName: icon) - .foregroundColor(color) - ZStack { - Text(text).fontWeight(.semibold).foregroundColor(.clear) - Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) - } - } - .onTapGesture { - setActiveFilter(filter: .presetTag(tag)) - } + expandedTagFilterView(tag) } } } @@ -787,9 +806,10 @@ struct ChatTagsView: View { nil } Menu { - if selectedPresetTag != nil { + if chatTagsModel.activeFilter != nil || !searchText.isEmpty { Button { chatTagsModel.activeFilter = nil + searchText = "" } label: { HStack { Image(systemName: "list.bullet") @@ -798,7 +818,7 @@ struct ChatTagsView: View { } } ForEach(PresetTag.allCases, id: \.id) { tag in - if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse { Button { setActiveFilter(filter: .presetTag(tag)) } label: { @@ -811,7 +831,7 @@ struct ChatTagsView: View { } } } label: { - if let tag = selectedPresetTag { + if let tag = selectedPresetTag, tag.сollapse { let (systemName, _) = presetTagLabel(tag: tag, active: true) Image(systemName: systemName) .foregroundColor(.accentColor) @@ -825,13 +845,15 @@ struct ChatTagsView: View { private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) { switch tag { + case .groupReports: (active ? "flag.fill" : "flag", "Reports") case .favorites: (active ? "star.fill" : "star", "Favorites") case .contacts: (active ? "person.fill" : "person", "Contacts") case .groups: (active ? "person.2.fill" : "person.2", "Groups") case .business: (active ? "briefcase.fill" : "briefcase", "Businesses") + case .notes: (active ? "folder.fill" : "folder", "Notes") } } - + private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -852,8 +874,10 @@ func chatStoppedIcon() -> some View { } } -func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { +func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { + case .groupReports: + chatStats.reportsCount > 0 case .favorites: chatInfo.chatSettings?.favorite == true case .contacts: @@ -871,6 +895,11 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { } case .business: chatInfo.groupInfo?.businessChat?.chatType == .business + case .notes: + switch chatInfo { + case .local: true + default: false + } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 13701a40a2..654bb56441 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -248,16 +248,20 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type func markedDeletedText() -> String { - switch cItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) - case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") - case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") - case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + if cItem.meta.itemDeleted != nil, cItem.isReport { + "archived report" + } else { + switch cItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) + case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") + case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") + case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + } } } @@ -270,6 +274,13 @@ struct ChatPreviewView: View { default: return nil } } + + func prefix() -> Text { + switch cItem.content.msgContent { + case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + default: return Text("") + } + } } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { @@ -302,6 +313,7 @@ struct ChatPreviewView: View { } @ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View { + let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no let mc = ci.content.msgContent switch mc { case let .link(_, preview): @@ -323,7 +335,17 @@ struct ChatPreviewView: View { .cornerRadius(8) } .onTapGesture { - UIApplication.shared.open(preview.uri) + switch privacyChatListOpenLinksDefault.get() { + case .yes: UIApplication.shared.open(preview.uri) + case .no: ItemsModel.shared.loadOpenChat(chat.id) + case .ask: AlertManager.shared.showAlert( + Alert(title: Text("Open web link?"), + message: Text(preview.uri.absoluteString), + primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }), + secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) }) + ) + ) + } } } case let .image(_, image): @@ -388,6 +410,8 @@ struct ChatPreviewView: View { case .group: if progressByTimeout { ProgressView() + } else if chat.chatStats.reportsCount > 0 { + groupReportsIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -433,6 +457,14 @@ struct ChatPreviewView: View { } } +@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View { + Image(systemName: "flag") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundColor(.red) +} + func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { view() .frame(width: size, height: size) diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift new file mode 100644 index 0000000000..8811234f52 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -0,0 +1,408 @@ +// +// TagListView.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 31/12/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import ElegantEmojiPicker + +struct TagEditorNavParams { + let chat: Chat? + let chatListTag: ChatTagData? + let tagId: Int64? +} + +struct TagListView: View { + var chat: Chat? = nil + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var m: ChatModel + @State private var editMode = EditMode.inactive + @State private var tagEditorNavParams: TagEditorNavParams? = nil + + var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] } + + var body: some View { + List { + Section { + ForEach(chatTagsModel.userTags, id: \.id) { tag in + let text = tag.chatTagText + let emoji = tag.chatTagEmoji + let tagId = tag.chatTagId + let selected = chatTagsIds.contains(tagId) + + HStack { + if let emoji { + Text(emoji) + } else { + Image(systemName: "tag") + } + Text(text) + .padding(.leading, 12) + Spacer() + if chat != nil { + radioButton(selected: selected) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if let c = chat { + setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() } + } else { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + showAlert( + NSLocalizedString("Delete list?", comment: "alert title"), + message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ), + UIAlertAction( + title: NSLocalizedString("Delete", comment: "alert action"), + style: .destructive, + handler: { _ in + deleteTag(tagId) + } + ) + ]} + ) + } label: { + Label("Delete", systemImage: "trash.fill") + } + .tint(.red) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(theme.colors.primary) + } + .background( + // isActive required to navigate to edit view from any possible tag edited in swipe action + NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) { + if let params = tagEditorNavParams { + TagListEditor( + chat: params.chat, + tagId: params.tagId, + emoji: params.chatListTag?.emoji, + name: params.chatListTag?.text ?? "" + ) + } + } label: { + EmptyView() + } + .opacity(0) + ) + } + .onMove(perform: moveItem) + + NavigationLink { + TagListEditor(chat: chat) + } label: { + Label("Create list", systemImage: "plus") + } + } header: { + if chat == nil { + editTagsButton() + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .modifier(ThemedBackground(grouped: true)) + .environment(\.editMode, $editMode) + } + + private func editTagsButton() -> some View { + if editMode.isEditing { + Button("Done") { + editMode = .inactive + dismiss() + } + } else { + Button("Edit") { + editMode = .active + } + } + } + + @ViewBuilder private func radioButton(selected: Bool) -> some View { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) + } + + private func moveItem(from source: IndexSet, to destination: Int) { + Task { + do { + var tags = chatTagsModel.userTags + tags.move(fromOffsets: source, toOffset: destination) + try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId }) + + await MainActor.run { + chatTagsModel.userTags = tags + } + } catch let error { + showAlert( + NSLocalizedString("Error reordering lists", comment: "alert title"), + message: responseError(error) + ) + } + } + } + + private func deleteTag(_ tagId: Int64) { + Task { + try await apiDeleteChatTag(tagId: tagId) + + await MainActor.run { + chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId } + if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId { + chatTagsModel.activeFilter = nil + } + m.chats.forEach { c in + if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) { + contact.chatTags = contact.chatTags.filter({ $0 != tagId }) + m.updateContact(contact) + } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) { + group.chatTags = group.chatTags.filter({ $0 != tagId }) + m.updateGroup(group) + } + } + } + } + } +} + +private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) { + Task { + do { + let tagIds: [Int64] = if let t = tagId { [t] } else {[]} + let (userTags, chatTags) = try await apiSetChatTags( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + tagIds: tagIds + ) + + await MainActor.run { + let m = ChatModel.shared + let tm = ChatTagsModel.shared + tm.userTags = userTags + if chat.unreadTag, let tags = chat.chatInfo.chatTags { + tm.decTagsReadCount(tags) + } + if var contact = chat.chatInfo.contact { + contact.chatTags = chatTags + m.updateContact(contact) + } else if var group = chat.chatInfo.groupInfo { + group.chatTags = chatTags + m.updateGroup(group) + } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false) + closeSheet() + } + } catch let error { + showAlert( + NSLocalizedString("Error saving chat list", comment: "alert title"), + message: responseError(error) + ) + } + } +} + +struct EmojiPickerView: UIViewControllerRepresentable { + @Binding var selectedEmoji: String? + @Binding var showingPicker: Bool + @Environment(\.presentationMode) var presentationMode + + class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate { + var parent: EmojiPickerView + + init(parent: EmojiPickerView) { + self.parent = parent + } + + func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) { + parent.selectedEmoji = emoji?.emoji + parent.showingPicker = false + picker.dismiss(animated: true) + } + + // Called when the picker is dismissed manually (without selection) + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + parent.showingPicker = false + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false) + let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config) + + picker.presentationController?.delegate = context.coordinator + + let viewController = UIViewController() + DispatchQueue.main.async { + if let topVC = getTopViewController() { + topVC.present(picker, animated: true) + } + } + + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // No need to update the controller after creation + } +} + +struct TagListEditor: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var theme: AppTheme + var chat: Chat? = nil + var tagId: Int64? = nil + var emoji: String? + var name: String = "" + @State private var newEmoji: String? + @State private var newName: String = "" + @State private var isPickerPresented = false + @State private var saving: Bool? + + var body: some View { + VStack { + List { + let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in + tag.chatTagId != tagId && + ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName) + } + + Section { + HStack { + Button { + isPickerPresented = true + } label: { + if let newEmoji { + Text(newEmoji) + } else { + Image(systemName: "face.smiling") + .foregroundColor(.secondary) + } + } + TextField("List name...", text: $newName) + } + + Button { + saving = true + if let tId = tagId { + updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName)) + } else { + createChatTag() + } + } label: { + Text( + chat != nil + ? "Add to list" + : "Save list" + ) + } + .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) + } footer: { + if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering + HStack { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + Text("List name and emoji should be different for all lists.") + .foregroundColor(theme.colors.secondary) + } + } + } + } + + if isPickerPresented { + EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + newEmoji = emoji + newName = name + } + } + + var trimmedName: String { + newName.trimmingCharacters(in: .whitespaces) + } + + private func createChatTag() { + Task { + do { + let text = trimmedName + let userTags = try await apiCreateChatTag( + tag: ChatTagData(emoji: newEmoji , text: text) + ) + await MainActor.run { + saving = false + chatTagsModel.userTags = userTags + } + if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) { + setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() } + } else { + await MainActor.run { dismiss() } + } + } catch let error { + await MainActor.run { + saving = nil + showAlert( + NSLocalizedString("Error creating list", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) { + Task { + do { + try await apiUpdateChatTag(tagId: tagId, tag: chatTagData) + await MainActor.run { + saving = false + for i in 0.., - _ alert: Binding + _ alert: Binding, + _ migration: Bool ) async -> Bool { if archivePath.startAccessingSecurityScopedResource() { + defer { + archivePath.stopAccessingSecurityScopedResource() + } await MainActor.run { progressIndicator.wrappedValue = true } @@ -483,17 +486,17 @@ struct DatabaseView: View { _ = kcDatabasePassword.remove() if archiveErrors.isEmpty { await operationEnded(.archiveImported, progressIndicator, alert) + return true } else { await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert) + return migration } - return true } catch let error { await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) } } catch let error { await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) } - archivePath.stopAccessingSecurityScopedResource() } else { showAlert("Error accessing database file") } @@ -542,6 +545,8 @@ struct DatabaseView: View { } else if case .chatDeleted = dbAlert { let (title, message) = chatDeletedAlertText() showAlert(title, message: message, actions: { [okAlertActionWaiting] }) + } else if case let .error(title, error) = dbAlert { + showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) } else { alert.wrappedValue = dbAlert cont.resume() @@ -587,13 +592,13 @@ struct DatabaseView: View { } } -private func archiveImportedAlertText() -> (String, String) { +func archiveImportedAlertText() -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") ) } -private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { +func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 763cd473fe..2d83cdc7c8 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -96,6 +96,7 @@ struct MigrateToDevice: View { @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var alert: MigrateToDeviceViewAlert? + @State private var databaseAlert: DatabaseAlert? = nil private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil // Prevent from hiding the view until migration is finished or app deleted @@ -178,6 +179,20 @@ struct MigrateToDevice: View { return Alert(title: Text(title), message: Text(error)) } } + .alert(item: $databaseAlert) { item in + switch item { + case .archiveImported: + let (title, message) = archiveImportedAlertText() + return Alert(title: Text(title), message: Text(message)) + case let .archiveImportedWithErrors(errs): + let (title, message) = archiveImportedWithErrorsAlertText(errs: errs) + return Alert(title: Text(title), message: Text(message)) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + default: // not expected this branch to be called because this alert is used only for importArchive purpose + return Alert(title: Text("Error")) + } + } .interactiveDismissDisabled(backDisabled) } @@ -243,7 +258,7 @@ struct MigrateToDevice: View { ) { result in if case let .success(files) = result, let fileURL = files.first { Task { - let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil)) + let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true) if success { DatabaseView.startChat( Binding.constant(false), diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 44e0b20958..00532c0a8e 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,6 +38,7 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -77,6 +78,7 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 513a6c2708..54454b7cef 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -54,6 +54,13 @@ struct DeveloperView: View { settingsRow("internaldrive", color: theme.colors.secondary) { Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) } + NavigationLink { + StorageView() + .navigationTitle("Storage") + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("internaldrive", color: theme.colors.secondary) { Text("Storage") } + } } header: { Text("Developer options") } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index cea9dd0635..24da6a94a8 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -53,7 +53,7 @@ struct OperatorView: View { ServersErrorView(errStr: errStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { - case let .accepted(acceptedAt): + case let .accepted(acceptedAt, _): if let acceptedAt = acceptedAt { Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 62aad348a7..0b9d1ef76c 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,6 +14,7 @@ struct PrivacySettings: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true + @State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get() @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @@ -74,6 +75,17 @@ struct PrivacySettings: View { privacyLinkPreviewsGroupDefault.set(linkPreviews) } } + settingsRow("arrow.up.right.circle", color: theme.colors.secondary) { + Picker("Open links from chat list", selection: $chatListOpenLinks) { + ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in + Text(mode.text) + } + } + } + .frame(height: 36) + .onChange(of: chatListOpenLinks) { mode in + privacyChatListOpenLinksDefault.set(mode) + } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 8a4ccce91b..138c3689f5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -29,6 +29,7 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group +let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" @@ -182,6 +183,8 @@ let connectViaLinkTabDefault = EnumDefault(defaults: UserDefa let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) +let privacyChatListOpenLinksDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask) + let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET) diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift new file mode 100644 index 0000000000..2cf63692a7 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -0,0 +1,56 @@ +// +// StorageView.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 13.01.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct StorageView: View { + @State var appGroupFiles: [String: Int64] = [:] + @State var documentsFiles: [String: Int64] = [:] + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + directoryView("App group:", appGroupFiles) + if !documentsFiles.isEmpty { + directoryView("Documents:", documentsFiles) + } + } + } + .padding() + .onAppear { + appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) + documentsFiles = traverseFiles(in: getDocumentsDirectory()) + } + } + + @ViewBuilder + private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { + Text(name).font(.headline) + ForEach(Array(contents), id: \.key) { (key, value) in + Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))") + } + } + + private func traverseFiles(in dir: URL) -> [String: Int64] { + var res: [String: Int64] = [:] + let fm = FileManager.default + do { + if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .fileAllocatedSizeKey]) { + for case let url as URL in enumerator { + let attrs = try url.resourceValues(forKeys: [/*.isDirectoryKey, .fileSizeKey,*/ .fileAllocatedSizeKey]) + let root = String(url.absoluteString.replacingOccurrences(of: dir.absoluteString, with: "").split(separator: "/")[0]) + res[root] = (res[root] ?? 0) + Int64(attrs.fileAllocatedSize ?? 0) + } + } + } catch { + logger.error("Error traversing files: \(error)") + } + return res + } +} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 7cd86ef1ef..781ea4bc34 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -298,6 +298,7 @@ struct UserProfilesView: View { private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async { do { if user.activeUser { + ChatModel.shared.removeWallpaperFilesFromAllChats(user) if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) try await deleteUser() @@ -323,6 +324,7 @@ struct UserProfilesView: View { func deleteUser() async throws { try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd) + removeWallpaperFilesFromTheme(user.uiThemes) await MainActor.run { withAnimation { m.removeUser(user) } } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ef98ddc678..641c1e9fd1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -200,9 +200,11 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.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 */; }; 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 */; }; + B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; }; B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; @@ -517,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -549,9 +551,11 @@ 8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = ""; }; + 8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; + B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; @@ -673,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, ); path = Libraries; sourceTree = ""; @@ -945,6 +949,7 @@ 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, + 8CBC14852D357CDB00BBD901 /* StorageView.swift */, ); path = UserSettings; sourceTree = ""; @@ -962,6 +967,7 @@ 18415835CBD939A9ABDC108A /* UserPicker.swift */, 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */, E51CC1E52C62085600DB91FE /* OneHandUICard.swift */, + B70A39722D24090D00E80A5F /* TagListView.swift */, ); path = ChatList; sourceTree = ""; @@ -1457,6 +1463,7 @@ 8C7F8F0E2C19C0C100D16888 /* ViewModifiers.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */, 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */, @@ -1526,6 +1533,7 @@ 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, + B70A39732D24090D00E80A5F /* TagListView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 6440CA00288857A10062C672 /* CIEventView.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, @@ -1935,7 +1943,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1960,7 +1968,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1984,7 +1992,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2009,7 +2017,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2025,11 +2033,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2045,11 +2053,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2070,7 +2078,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2085,7 +2093,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2107,7 +2115,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2122,7 +2130,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2144,7 +2152,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2170,7 +2178,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2195,7 +2203,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2221,7 +2229,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2246,7 +2254,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2261,7 +2269,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2280,7 +2288,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2295,7 +2303,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b6a8edb201..4ae9bda0f2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -51,6 +51,7 @@ public enum ChatCommand { case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) case apiReorderChatTags(tagIds: [Int64]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) @@ -88,8 +89,9 @@ public enum ChatCommand { case apiGetUsageConditions case apiSetConditionsNotified(conditionsId: Int64) case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) - case apiSetChatItemTTL(userId: Int64, seconds: Int64?) + case apiSetChatItemTTL(userId: Int64, seconds: Int64) case apiGetChatItemTTL(userId: Int64) + case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig case apiSetNetworkInfo(networkInfo: UserNetworkInfo) @@ -123,6 +125,7 @@ public enum ChatCommand { case apiUpdateProfile(userId: Int64, profile: Profile) case apiSetContactPrefs(contactId: Int64, preferences: Preferences) case apiSetContactAlias(contactId: Int64, localAlias: String) + case apiSetGroupAlias(groupId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) @@ -221,6 +224,8 @@ public enum ChatCommand { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -262,6 +267,7 @@ public enum ChatCommand { case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" @@ -305,6 +311,7 @@ public enum ChatCommand { case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" @@ -390,6 +397,7 @@ public enum ChatCommand { case .apiUpdateChatTag: return "apiUpdateChatTag" case .apiReorderChatTags: return "apiReorderChatTags" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -430,6 +438,7 @@ public enum ChatCommand { case .apiAcceptConditions: return "apiAcceptConditions" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" + case .apiSetChatTTL: return "apiSetChatTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .apiSetNetworkInfo: return "apiSetNetworkInfo" @@ -462,6 +471,7 @@ public enum ChatCommand { case .apiUpdateProfile: return "apiUpdateProfile" case .apiSetContactPrefs: return "apiSetContactPrefs" case .apiSetContactAlias: return "apiSetContactAlias" + case .apiSetGroupAlias: return "apiSetGroupAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias" case .apiSetUserUIThemes: return "apiSetUserUIThemes" case .apiSetChatUIThemes: return "apiSetChatUIThemes" @@ -519,7 +529,7 @@ public enum ChatCommand { if let seconds = seconds { return String(seconds) } else { - return "none" + return "default" } } @@ -625,6 +635,7 @@ public enum ChatResponse: Decodable, Error { case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) case userPrivacy(user: User, updatedUser: User) case contactAliasUpdated(user: UserRef, toContact: Contact) + case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) case userContactLink(user: User, contactLink: UserContactLink) @@ -646,6 +657,7 @@ public enum ChatResponse: Decodable, Error { case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItems(user: UserRef, chatItems: [AChatItem]) + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case chatItemUpdated(user: UserRef, chatItem: AChatItem) @@ -804,6 +816,7 @@ public enum ChatResponse: Decodable, Error { case .userProfileUpdated: return "userProfileUpdated" case .userPrivacy: return "userPrivacy" case .contactAliasUpdated: return "contactAliasUpdated" + case .groupAliasUpdated: return "groupAliasUpdated" case .connectionAliasUpdated: return "connectionAliasUpdated" case .contactPrefsUpdated: return "contactPrefsUpdated" case .userContactLink: return "userContactLink" @@ -825,6 +838,7 @@ public enum ChatResponse: Decodable, Error { case .groupEmpty: return "groupEmpty" case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItems: return "newChatItems" + case .groupChatItemsDeleted: return "groupChatItemsDeleted" case .forwardPlan: return "forwardPlan" case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" case .chatItemUpdated: return "chatItemUpdated" @@ -981,6 +995,7 @@ public enum ChatResponse: Decodable, Error { case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) @@ -1004,6 +1019,8 @@ public enum ChatResponse: Decodable, Error { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") case let .chatItemsStatusesUpdated(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") @@ -1186,12 +1203,14 @@ public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) var cmdString: String { switch self { case let .last(count): return "count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" } } } @@ -1324,7 +1343,7 @@ public struct ServerOperatorConditions: Decodable { } public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?) + case accepted(acceptedAt: Date?, autoAccepted: Bool) // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. // No deadline indicates it's required to accept conditions for the operator to start using it. case required(deadline: Date?) @@ -1398,7 +1417,7 @@ public struct ServerOperator: Identifiable, Equatable, Codable { tradeName: "SimpleX Chat", legalName: "SimpleX Chat Ltd", serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: true, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) @@ -1431,7 +1450,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable { tradeName: "", legalName: "", serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) @@ -2207,6 +2226,22 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } +public enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { + case yes + case no + case ask + + public var id: Self { self } + + public var text: LocalizedStringKey { + switch self { + case .yes: return "Yes" + case .no: return "No" + case .ask: return "Ask" + } + } +} + public struct RemoteCtrlInfo: Decodable { public var remoteCtrlId: Int64 public var ctrlDeviceName: String @@ -2471,6 +2506,7 @@ public enum ProtocolErrorType: Decodable, Hashable { case CMD(cmdErr: ProtocolCommandError) indirect case PROXY(proxyErr: ProxyError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case CRYPTO case QUOTA case STORE(storeErr: String) @@ -2487,11 +2523,28 @@ public enum ProxyError: Decodable, Hashable { case NO_SESSION } +public struct BlockingInfo: Decodable, Equatable, Hashable { + public var reason: BlockingReason +} + +public enum BlockingReason: String, Decodable { + case spam + case content + + public var text: String { + switch self { + case .spam: NSLocalizedString("Spam", comment: "blocking reason") + case .content: NSLocalizedString("Content violates conditions of use", comment: "blocking reason") + } + } +} + public enum XFTPErrorType: Decodable, Hashable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case SIZE case QUOTA case DIGEST @@ -2630,6 +2683,7 @@ public struct AppSettings: Codable, Equatable { public var privacyAskToApproveRelays: Bool? = nil public var privacyAcceptImages: Bool? = nil public var privacyLinkPreviews: Bool? = nil + public var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil public var privacyShowChatPreviews: Bool? = nil public var privacySaveLastDraft: Bool? = nil public var privacyProtectScreen: Bool? = nil @@ -2665,6 +2719,7 @@ public struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2701,6 +2756,7 @@ public struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, + privacyChatListOpenLinks: .ask, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b1a318c896..ae49ee3f3f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,6 +9,14 @@ import Foundation import SwiftUI +// version to establishing direct connection with a group member (xGrpDirectInvVRange in core) +public let CREATE_MEMBER_CONTACT_VERSION = 2 + +// version to receive reports (MCReport) +public let REPORTS_VERSION = 12 + +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1492,6 +1500,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .invalidJSON: return .now } } + + public func ttl(_ globalTTL: ChatItemTTL) -> ChatTTL { + switch self { + case let .direct(contact): + return if let ciTTL = contact.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + case let .group(groupInfo): + return if let ciTTL = groupInfo.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + default: return ChatTTL.userDefault(globalTTL) + } + } public struct SampleData: Hashable { public var direct: ChatInfo @@ -1533,13 +1559,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { self.unreadCount = unreadCount + self.reportsCount = reportsCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } public var unreadCount: Int = 0 + // actual only via getChats() and getChat(.initial), otherwise, zero + public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 public var unreadChat: Bool = false } @@ -1561,6 +1590,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var contactGroupMemberId: Int64? var contactGrpInvSent: Bool public var chatTags: [Int64] + public var chatItemTTL: Int64? public var uiThemes: ThemeModeOverrides? public var chatDeleted: Bool @@ -1695,7 +1725,7 @@ public struct Connection: Decodable, Hashable { static let sampleData = Connection( connId: 1, agentConnId: "abc", - peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + peerChatVRange: VersionRange(1, 1), connStatus: .ready, connLevel: 0, viaGroupLink: false, @@ -1707,17 +1737,13 @@ public struct Connection: Decodable, Hashable { } public struct VersionRange: Decodable, Hashable { - public init(minVersion: Int, maxVersion: Int) { + public init(_ minVersion: Int, _ maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion } public var minVersion: Int public var maxVersion: Int - - public func isCompatibleRange(_ vRange: VersionRange) -> Bool { - self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion - } } public struct SecurityCode: Decodable, Equatable, Hashable { @@ -1769,7 +1795,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public static let sampleData = UserContactRequest( contactRequestId: 1, userContactLinkId: 1, - cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", profile: Profile.sampleData, createdAt: .now, @@ -1923,11 +1949,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } public var sendMsgEnabled: Bool { get { membership.memberActive } } - public var displayName: String { get { groupProfile.displayName } } + public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } - public var localAlias: String { "" } public var chatTags: [Int64] + public var chatItemTTL: Int64? + public var localAlias: String public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -1951,7 +1978,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, - chatTags: [] + chatTags: [], + localAlias: "" ) } @@ -2008,6 +2036,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } public var ready: Bool { get { activeConn?.connStatus == .ready } } @@ -2102,7 +2131,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } + return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { @@ -2110,7 +2139,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable { return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } + + public var canReceiveReports: Bool { + memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION + } + public var versionRange: VersionRange { + if let activeConn { + activeConn.peerChatVRange + } else { + memberChatVRange + } + } + public var memberIncognito: Bool { memberProfile.profileId != memberContactProfileId } @@ -2129,7 +2170,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2148,19 +2190,23 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { - case observer = "observer" - case author = "author" - case member = "member" - case admin = "admin" - case owner = "owner" + case observer + case author + case member + case moderator + case admin + case owner public var id: Self { self } + public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner] + public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") + case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") } @@ -2168,11 +2214,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: return 0 - case .author: return 1 - case .member: return 2 - case .admin: return 3 - case .owner: return 4 + case .observer: 0 + case .author: 1 + case .member: 2 + case .moderator: 3 + case .admin: 4 + case .owner: 5 } } @@ -2578,6 +2625,21 @@ public struct ChatItem: Identifiable, Decodable, Hashable { default: return true } } + + public var isReport: Bool { + switch content { + case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent): + switch msgContent { + case .report: true + default: false + } + default: false + } + } + + public var isActiveReport: Bool { + isReport && !isDeletedContent && meta.itemDeleted == nil + } public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete @@ -2663,6 +2725,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable { file: nil ) } + + public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem { + let chatDir = if let sender = sender { + CIDirection.groupRcv(groupMember: sender) + } else { + CIDirection.groupSnd + } + + return ChatItem( + chatDir: chatDir, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: false, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), + quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), + file: nil + ) + } public static func deletedItemDummy() -> ChatItem { ChatItem( @@ -2957,7 +3047,7 @@ public enum SndError: Decodable, Hashable { case proxyRelay(proxyServer: String, srvError: SrvError) case other(sndError: String) - public var errorInfo: String { + public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text") case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text") @@ -3102,6 +3192,7 @@ public enum CIForwardedFrom: Decodable, Hashable { public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" + case cidmInternalMark = "internalMark" } protocol ItemContent { @@ -3276,14 +3367,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) default: return content.text } } - public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { case .directSnd: return "you" @@ -3347,9 +3436,11 @@ public enum MREmojiChar: String, Codable, CaseIterable, Hashable { case thumbsup = "👍" case thumbsdown = "👎" case smile = "😀" + case laugh = "😂" case sad = "😢" case heart = "❤" case launch = "🚀" + case check = "✅" } extension MsgReaction: Decodable { @@ -3616,6 +3707,7 @@ public enum CIFileStatus: Decodable, Equatable, Hashable { public enum FileError: Decodable, Equatable, Hashable { case auth + case blocked(server: String, blockInfo: BlockingInfo) case noFile case relay(srvError: SrvError) case other(fileError: String) @@ -3623,6 +3715,7 @@ public enum FileError: Decodable, Equatable, Hashable { var id: String { switch self { case .auth: return "auth" + case let .blocked(srv, info): return "blocked \(srv) \(info)" case .noFile: return "noFile" case let .relay(srvError): return "relay \(srvError)" case let .other(fileError): return "other \(fileError)" @@ -3632,11 +3725,19 @@ public enum FileError: Decodable, Equatable, Hashable { public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") + case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text") case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) } } + + public var moreInfoButton: (label: LocalizedStringKey, link: URL)? { + switch self { + case .blocked: ("How it works", contentModerationPostLink) + default: nil + } + } } public enum MsgContent: Equatable, Hashable { @@ -3646,6 +3747,7 @@ public enum MsgContent: Equatable, Hashable { case video(text: String, image: String, duration: Int) case voice(text: String, duration: Int) case file(String) + case report(text: String, reason: ReportReason) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -3657,6 +3759,7 @@ public enum MsgContent: Equatable, Hashable { case let .video(text, _, _): return text case let .voice(text, _): return text case let .file(text): return text + case let .report(text, _): return text case let .unknown(_, text): return text } } @@ -3716,6 +3819,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3726,6 +3830,7 @@ public enum MsgContent: Equatable, Hashable { case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf + case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -3761,6 +3866,10 @@ extension MsgContent: Decodable { case "file": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .file(text) + case "report": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let reason = try container.decode(ReportReason.self, forKey: CodingKeys.reason) + self = .report(text: text, reason: reason) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -3798,6 +3907,10 @@ extension MsgContent: Encodable { case let .file(text): try container.encode("file", forKey: .type) try container.encode(text, forKey: .text) + case let .report(text, reason): + try container.encode("report", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(reason, forKey: .reason) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -3877,6 +3990,57 @@ public enum FormatColor: String, Decodable, Hashable { } } +public enum ReportReason: Hashable { + case spam + case illegal + case community + case profile + case other + case unknown(type: String) + + public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other] + + public var text: String { + switch self { + case .spam: return NSLocalizedString("Spam", comment: "report reason") + case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason") + case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason") + case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason") + case .other: return NSLocalizedString("Another reason", comment: "report reason") + case let .unknown(type): return type + } + } +} + +extension ReportReason: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .spam: try container.encode("spam") + case .illegal: try container.encode("illegal") + case .community: try container.encode("community") + case .profile: try container.encode("profile") + case .other: try container.encode("other") + case let .unknown(type): try container.encode(type) + } + } +} + +extension ReportReason: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "spam": self = .spam + case "illegal": self = .illegal + case "community": self = .community + case "profile": self = .profile + case "other": self = .other + default: self = .unknown(type: type) + } + } +} + // Struct to use with simplex API public struct LinkPreview: Codable, Equatable, Hashable { public init(uri: URL, title: String, description: String = "", image: String) { @@ -4191,45 +4355,53 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { case day case week case month + case year case seconds(_ seconds: Int64) case none - public static var values: [ChatItemTTL] { [.none, .month, .week, .day] } + public static var values: [ChatItemTTL] { [.none, .year, .month, .week, .day] } public var id: Self { self } - public init(_ seconds: Int64?) { + public init(_ seconds: Int64) { switch seconds { + case 0: self = .none case 86400: self = .day case 7 * 86400: self = .week case 30 * 86400: self = .month - case let .some(n): self = .seconds(n) - case .none: self = .none + case 365 * 86400: self = .year + default: self = .seconds(seconds) } } - public var deleteAfterText: LocalizedStringKey { + public var deleteAfterText: String { switch self { - case .day: return "1 day" - case .week: return "1 week" - case .month: return "1 month" - case let .seconds(seconds): return "\(seconds) second(s)" - case .none: return "never" + case .day: return NSLocalizedString("1 day", comment: "delete after time") + case .week: return NSLocalizedString("1 week", comment: "delete after time") + case .month: return NSLocalizedString("1 month", comment: "delete after time") + case .year: return NSLocalizedString("1 year", comment: "delete after time") + case let .seconds(seconds): return String.localizedStringWithFormat(NSLocalizedString("%d seconds(s)", comment: "delete after time"), seconds) + case .none: return NSLocalizedString("never", comment: "delete after time") } } - public var seconds: Int64? { + public var seconds: Int64 { switch self { case .day: return 86400 case .week: return 7 * 86400 case .month: return 30 * 86400 + case .year: return 365 * 86400 case let .seconds(seconds): return seconds - case .none: return nil + case .none: return 0 } } private var comparisonValue: Int64 { - self.seconds ?? Int64.max + if self.seconds == 0 { + return Int64.max + } else { + return self.seconds + } } public static func < (lhs: Self, rhs: Self) -> Bool { @@ -4237,6 +4409,43 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { } } +public enum ChatTTL: Identifiable, Hashable { + case userDefault(ChatItemTTL) + case chat(ChatItemTTL) + + public var id: Self { self } + + public var text: String { + switch self { + case let .chat(ttl): return ttl.deleteAfterText + case let .userDefault(ttl): return String.localizedStringWithFormat( + NSLocalizedString("default (%@)", comment: "delete after time"), + ttl.deleteAfterText) + } + } + + public var neverExpires: Bool { + switch self { + case let .chat(ttl): return ttl.seconds == 0 + case let .userDefault(ttl): return ttl.seconds == 0 + } + } + + public var value: Int64? { + switch self { + case let .chat(ttl): return ttl.seconds + case .userDefault: return nil + } + } + + public var usingDefault: Bool { + switch self { + case .userDefault: return true + case .chat: return false + } + } +} + public struct ChatTag: Decodable, Hashable { public var chatTagId: Int64 public var chatTagText: String diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index f246bd12cc..0e27f5c0b1 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -41,7 +41,7 @@ public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } -func getGroupContainerDirectory() -> URL { +public func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index ab4246a0b1..36c38dc728 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -275,17 +275,26 @@ public func saveWallpaperFile(image: UIImage) -> String? { public func removeWallpaperFile(fileName: String? = nil) { do { - try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach { - if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) } + try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in + if url.lastPathComponent == fileName { + try FileManager.default.removeItem(at: url) + } } } catch { - logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)") + logger.error("FileUtils.removeWallpaperFile error: \(error)") } if let fileName { WallpaperType.cachedImages.removeValue(forKey: fileName) } } +public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) { + if let theme { + removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile) + removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile) + } +} + public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String { uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) } diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 67bc0d70c8..bb6a6f8f8a 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + + = Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(video) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ad67b7cf1e..5b7f89f2df 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -87,6 +87,9 @@ kotlin { implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-gif:2.6.0") + // Emojis + implementation("androidx.emoji2:emoji2-emojipicker:1.4.0") + implementation("com.jakewharton:process-phoenix:3.0.0") val cameraXVersion = "1.3.4" diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index bfe961a512..03012e318c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -19,6 +19,8 @@ actual val wallpapersDir: File = File(filesDir.absolutePath + File.separator + " actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files") actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files" actual val preferencesDir = File(dataDir.absolutePath + File.separator + "shared_prefs") +actual val preferencesTmpDir = File(tmpDir, "prefs_tmp") + .also { it.deleteRecursively() } actual val chatDatabaseFileName: String = "files_chat.db" actual val agentDatabaseFileName: String = "files_agent.db" diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index 60197f3851..b3d8e9b52f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 385e6c82a4..79f0a8636b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -3,19 +3,30 @@ package chat.simplex.common.platform import android.Manifest import android.content.* import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.PackageManager import android.net.Uri import android.provider.MediaStore import android.webkit.MimeTypeMap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler +import androidx.core.graphics.drawable.toBitmap import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import java.io.BufferedOutputStream import java.io.File -import chat.simplex.res.MR +import java.net.URI import kotlin.math.min +data class OpenDefaultApp( + val name: String, + val icon: ImageBitmap, + val isSystemChooser: Boolean +) + actual fun ClipboardManager.shareText(text: String) { var text = text for (i in 10 downTo 1) { @@ -37,7 +48,7 @@ actual fun ClipboardManager.shareText(text: String) { } } -fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) { +fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean, useChooser: Boolean = true) { val uri = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() @@ -67,9 +78,35 @@ fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) { type = mimeType } } - val shareIntent = Intent.createChooser(sendIntent, null) - shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) - androidAppContext.startActivity(shareIntent) + if (useChooser) { + val shareIntent = Intent.createChooser(sendIntent, null) + shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) + androidAppContext.startActivity(shareIntent) + } else { + sendIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) + androidAppContext.startActivity(sendIntent) + } +} + +fun queryDefaultAppForExtension(ext: String, encryptedFileUri: URI): OpenDefaultApp? { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null + val openIntent = Intent(Intent.ACTION_VIEW) + openIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + openIntent.setDataAndType(encryptedFileUri.toUri(), mimeType) + val pm = androidAppContext.packageManager +//// This method returns the list of apps but no priority, nor default flag +// val resInfoList: List = if (Build.VERSION.SDK_INT >= 33) { +// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong())) +// } else { +// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY) +// }.sortedBy { it.priority } +// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null + val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null +// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}") + val label = act.loadLabel(pm).toString() + val icon = act.loadIcon(pm).toBitmap().asImageBitmap() + val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true + return OpenDefaultApp(label, icon, chooser) } actual fun shareFile(text: String, fileSource: CryptoFile) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index ae5966b20f..a1698ae28a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.clear import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* @@ -74,9 +75,16 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { - // Since no modals are open, the problem is probably in ChatView - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + // Since no modals are open, the problem is probably in ChatView + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + withChats { + chatItems.clearAndNotify() + } + } } else { // ChatList, nothing to do. Maybe to show other view except ChatList } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt new file mode 100644 index 0000000000..b24150ed24 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -0,0 +1,57 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import chat.simplex.common.model.CryptoFile +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.DefaultDropdownMenu +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null } + DefaultDropdownMenu(showMenu) { + if (defaultApp != null) { + if (!defaultApp.isSystemChooser) { + ItemAction( + stringResource(MR.strings.open_with_app).format(defaultApp.name), + defaultApp.icon, + textColor = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } else { + ItemAction( + stringResource(MR.strings.open_with_app).format("…"), + painterResource(MR.images.ic_open_in_new), + color = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } + } + ItemAction( + stringResource(MR.strings.save_verb), + painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download), + color = MaterialTheme.colors.primary, + onClick = { + saveFile() + showMenu.value = false + } + ) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 7db39b7d3e..8c3b161a5c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -29,6 +29,19 @@ private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFF private val CALL_BOTTOM_ICON_OFFSET = (-15).dp private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + Row( + modifier = Modifier + .padding(horizontal = 14.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val onClick = { platform.androidStartCallActivity(false) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt new file mode 100644 index 0000000000..ab6d375d75 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -0,0 +1,81 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import android.view.ViewGroup +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.emoji2.emojipicker.EmojiPickerView +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + Box(Modifier + .clip(shape = CircleShape) + .clickable { + ModalManager.start.showModalCloseable { close -> + EmojiPicker(close = { + close() + emoji.value = it + }) + } + } + .padding(4.dp) + ) { + val emojiValue = emoji.value + if (emojiValue != null) { + Text(emojiValue) + } else { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + } + Spacer(Modifier.width(8.dp)) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun EmojiPicker(close: (String?) -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPaddingToContent = topPaddingToContent(false) + + Column ( + modifier = Modifier.fillMaxSize().navigationBarsPadding().padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + ) { + AndroidView( + factory = { context -> + EmojiPickerView(context).apply { + emojiGridColumns = 10 + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setOnEmojiPickedListener { pickedEmoji -> + close(pickedEmoji.emoji) + } + } + } + ) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt index a39e71947d..0378fcbd7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt @@ -4,19 +4,31 @@ import android.Manifest import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.platform.ntfManager -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import com.google.accompanist.permissions.* @Composable actual fun SetNotificationsModeAdditions() { if (Build.VERSION.SDK_INT >= 33) { val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) LaunchedEffect(notificationsPermissionState.status == PermissionStatus.Granted) { - if (notificationsPermissionState.status == PermissionStatus.Granted) { - ntfManager.androidCreateNtfChannelsMaybeShowAlert() + val canAsk = appPrefs.canAskToEnableNotifications.get() + if (notificationsPermissionState.status is PermissionStatus.Denied) { + if (notificationsPermissionState.status.shouldShowRationale || !canAsk) { + if (canAsk) { + appPrefs.canAskToEnableNotifications.set(false) + } + Log.w(TAG, "Notifications are disabled and nobody will ask to enable them") + } else { + notificationsPermissionState.launchPermissionRequest() + } } else { - notificationsPermissionState.launchPermissionRequest() + if (!canAsk) { + // the user allowed notifications in system alert or manually in settings, allow to ask him next time if needed + appPrefs.canAskToEnableNotifications.set(true) + } + ntfManager.androidCreateNtfChannelsMaybeShowAlert() } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index fc17c49c7e..ba1eda8a7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -114,7 +114,7 @@ fun MainScreen() { @Composable fun AuthView() { - Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -223,7 +223,7 @@ fun MainScreen() { if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) { AuthView() } else { - SplashView() + SplashView(true) ModalManager.fullscreen.showPasscodeInView() } } else { @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, onComposed) + ChatView(currentChatId, reportsView = false, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId) {} + else -> ChatView(currentChatId, reportsView = false) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index c93fabec8b..d6f9640cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -113,7 +113,7 @@ object AppLock { val appPrefs = ChatController.appPrefs ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { ChatModel.showAuthScreen.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index db90c2e573..4eb0b350cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -8,11 +8,12 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.ChatModel.chatItemsChangesListener import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.contentModerationPostLink +import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState @@ -22,7 +23,6 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlin.collections.removeAll as remAll import kotlinx.datetime.* import kotlinx.datetime.TimeZone @@ -58,28 +58,26 @@ object ChatModel { val ctrlInitInProgress = mutableStateOf(false) val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) - private val _chats = mutableStateOf(SnapshotStateList()) - val chats: State> = _chats - private val chatsContext = ChatsContext() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) - /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. - * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. - * If you use api call to get the items, use just [add] instead of [addAndNotify]. - * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ - val chatItems = mutableStateOf(SnapshotStateList()) - // set listener here that will be notified on every add/delete of a chat item - var chatItemsChangesListener: ChatItemsChangesListener? = null - val chatState = ActiveChatState() + val chatsContext = ChatsContext(null) + val reportsChatsContext = ChatsContext(MsgContentTag.Report) + // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions + val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) - val chatItemStatuses = mutableMapOf() - val groupMembers = mutableStateListOf() - val groupMembersIndexes = mutableStateMapOf() + val groupMembers = mutableStateOf>(emptyList()) + val groupMembersIndexes = mutableStateOf>(emptyMap()) + + // Chat Tags + val userTags = mutableStateOf(emptyList()) + val activeChatTagFilter = mutableStateOf(null) + val presetTags = mutableStateMapOf() + val unreadTags = mutableStateMapOf() // false: default placement, true: floating window. // Used for deciding to add terminal items on main thread or not. Floating means appPrefs.terminalAlwaysVisible @@ -150,7 +148,6 @@ object ChatModel { val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null - private val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null @@ -170,6 +167,36 @@ object ChatModel { // return true if you handled the click var centerPanelBackgroundClickHandler: (() -> Boolean)? = null + fun chatsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chats + MsgContentTag.Report -> reportsChatsContext.chats + else -> TODO() + } + + fun chatItemsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chatItems + MsgContentTag.Report -> reportsChatsContext.chatItems + else -> TODO() + } + + fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) { + null -> chatsContext.chatState + MsgContentTag.Report -> reportsChatsContext.chatState + else -> TODO() + } + + fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) { + null -> chatsContext.chatItemsChangesListener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener + else -> TODO() + } + + fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) { + null -> chatsContext.chatItemsChangesListener = listener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener + else -> TODO() + } + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -196,35 +223,138 @@ object ChatModel { } } - // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null + fun updateChatTags(rhId: Long?) { + val newPresetTags = mutableMapOf() + val newUnreadTags = mutableMapOf() + + for (chat in chats.value.filter { it.remoteHostId == rhId }) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) { + newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 + } + } + if (chat.unreadTag) { + val chatTags: List = when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> cInfo.contact.chatTags + is ChatInfo.Group -> cInfo.groupInfo.chatTags + else -> emptyList() + } + chatTags.forEach { tag -> + newUnreadTags[tag] = (newUnreadTags[tag] ?: 0) + 1 + } + } + } + + if (activeChatTagFilter.value is ActiveFilter.PresetTag && + (newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) { + activeChatTagFilter.value = null + } + + presetTags.clear() + presetTags.putAll(newPresetTags) + unreadTags.clear() + unreadTags.putAll(newUnreadTags) + } + + fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) { + val count = presetTags[PresetTagKind.FAVORITES] + + if (favorite && !wasFavorite) { + presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1 + } else if (!favorite && wasFavorite && count != null) { + presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1) + if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) { + activeChatTagFilter.value = null + } + } + } + + private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { + presetTags[tag] = (presetTags[tag] ?: 0) + 1 + } + } + } + + fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { + val count = presetTags[tag] + if (count != null) { + presetTags[tag] = maxOf(0, count - 1) + } + } + } + } + + fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { + if (chat.unreadTag) { + oldTags?.forEach { t -> + val oldCount = unreadTags[t] + if (oldCount != null) { + unreadTags[t] = maxOf(0, oldCount - 1) + } + } + + newTags.forEach { t -> + unreadTags[t] = (unreadTags[t] ?: 0) + 1 + } + } + } // TODO pass rhId? fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun populateGroupMembersIndexes() { - groupMembersIndexes.clear() - groupMembers.forEachIndexed { i, member -> - groupMembersIndexes[member.groupMemberId] = i + groupMembersIndexes.value = emptyMap() + val gmIndexes = groupMembersIndexes.value.toMutableMap() + groupMembers.value.forEachIndexed { i, member -> + gmIndexes[member.groupMemberId] = i } + groupMembersIndexes.value = gmIndexes } fun getGroupMember(groupMemberId: Long): GroupMember? { - val memberIndex = groupMembersIndexes[groupMemberId] + val memberIndex = groupMembersIndexes.value[groupMemberId] return if (memberIndex != null) { - groupMembers[memberIndex] + groupMembers.value[memberIndex] } else { null } } - suspend fun withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock { - chatsContext.action() + // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread + suspend fun withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { + when { + contentTag == null -> chatsContext.action() + contentTag == MsgContentTag.Report -> reportsChatsContext.action() + else -> TODO() + } } - class ChatsContext { - val chats = _chats + suspend fun withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + reportsChatsContext.action() + } + } + + class ChatsContext(private val contentTag: MsgContentTag?) { + val chats = mutableStateOf(SnapshotStateList()) + /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. + * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. + * If you use api call to get the items, use just [add] instead of [addAndNotify]. + * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ + val chatItems = mutableStateOf(SnapshotStateList()) + val chatItemStatuses = mutableMapOf() + // set listener here that will be notified on every add/delete of a chat item + var chatItemsChangesListener: ChatItemsChangesListener? = null + val chatState = ActiveChatState() + + fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null + fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) @@ -263,6 +393,13 @@ object ChatModel { } } + fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) { + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + chats[i] = chats[i].copy(chatStats = chatStats) + } + } + suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) @@ -280,6 +417,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) + addPresetChatTags(cInfo, Chat.ChatStats()) } } @@ -329,6 +467,7 @@ object ChatModel { } else -> cItem } + val wasUnread = chat.unreadTag chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = @@ -339,6 +478,8 @@ object ChatModel { else chat.chatStats ) + updateChatTagReadNoContentTag(chats[i], wasUnread) + if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) } else { @@ -353,9 +494,9 @@ object ChatModel { // Prevent situation when chat item already in the list received from backend if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag) } else { - chatItems.addAndNotify(cItem) + chatItems.addAndNotify(cItem, contentTag) } } } @@ -374,7 +515,7 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } } res = false @@ -400,7 +541,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci) + chatItems.addAndNotify(ci, contentTag) true } } else { @@ -425,7 +566,7 @@ object ChatModel { fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } // update previews val i = getChatIndex(rhId, cInfo.id) @@ -455,6 +596,7 @@ object ChatModel { if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) + markChatTagRead(chats[i]) } // clear current chat if (chatId.value == cInfo.id) { @@ -463,9 +605,9 @@ object ChatModel { } } - val popChatCollector = PopChatCollector() + val popChatCollector = PopChatCollector(contentTag) - class PopChatCollector { + class PopChatCollector(contentTag: MsgContentTag?) { private val subject = MutableSharedFlow() private var remoteHostId: Long? = null private val chatsToPop = mutableMapOf() @@ -475,7 +617,7 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats { + withChats(contentTag) { chats.replaceAll(popCollectedChats()) } } @@ -513,40 +655,83 @@ object ChatModel { } } - fun markChatItemsRead(remoteHostId: Long?, chatInfo: ChatInfo, itemIds: List? = null) { - val cInfo = chatInfo - val markedRead = markItemsReadInCurrentChat(chatInfo, itemIds) + fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { + val markedRead = markItemsReadInCurrentChat(id, itemIds) // update preview - val chatIdx = getChatIndex(remoteHostId, cInfo.id) + val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { + val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) + updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + var markedRead = 0 + if (chatId.value == id) { + val items = chatItems.value + var i = items.lastIndex + val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() + val markedReadIds = mutableSetOf() + while (i >= 0) { + val item = items[i] + if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { + val newItem = item.withStatus(CIStatus.RcvRead()) + items[i] = newItem + if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { + items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( + deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) + ) + } + markedReadIds.add(item.id) + markedRead++ + if (itemIds != null) { + itemIdsFromRange.remove(item.id) + // already set all needed items as read, can finish the loop + if (itemIdsFromRange.isEmpty()) break + } + } + i-- + } + chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + } + return markedRead + } + + private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + val wasUnread = chat.unreadTag decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, ) ) + updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - chats.removeAll { it.id == id && it.remoteHostId == rhId } + val i = getChatIndex(rhId, id) + if (i != -1) { + val chat = chats.removeAt(i) + removePresetChatTags(chat.chatInfo, chat.chatStats) + removeWallpaperFilesFromChat(chat) + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -557,7 +742,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembersIndexes[member.groupMemberId] + val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { // Take into account only specific changes, not all. Other member updates are not important and can be skipped if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId && @@ -573,12 +758,17 @@ object ChatModel { if (updated != chatItems.value) { chatItems.replaceAll(updated) } + val gMembers = groupMembers.value.toMutableList() if (memberIndex != null) { - groupMembers[memberIndex] = member + gMembers[memberIndex] = member + groupMembers.value = gMembers false } else { - groupMembers.add(member) - groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1 + gMembers.add(member) + groupMembers.value = gMembers + val gmIndexes = groupMembersIndexes.value.toMutableMap() + gmIndexes[member.groupMemberId] = groupMembers.size - 1 + groupMembersIndexes.value = gmIndexes true } } else { @@ -594,9 +784,92 @@ object ChatModel { upsertGroupMember(rhId, groupInfo, updatedMember) } } - } - private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounterNoContentTag(rhId, user, 1) + } + + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounterNoContentTag(rhId, user, -by) + } + + private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) + } + } + + fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val tags = chat.chatInfo.chatTags ?: return + val nowUnread = chat.unreadTag + + if (nowUnread && !wasUnread) { + tags.forEach { tag -> + unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 + } + } else if (!nowUnread && wasUnread) { + markChatTagReadNoContentTag_(chat, tags) + } + } + + fun markChatTagRead(chat: Chat) { + if (chat.unreadTag) { + chat.chatInfo.chatTags?.let { tags -> + markChatTagReadNoContentTag_(chat, tags) + } + } + } + + private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + for (tag in tags) { + val count = unreadTags[tag] + if (count != null) { + unreadTags[tag] = maxOf(0, count - 1) + } + } + } + + fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, 1) + } + + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, -1) + } + + private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { + if (by == 0) return + + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + val chat = chats.value[i] + chats[i] = chat.copy( + chatStats = chat.chatStats.copy( + reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0), + ) + ) + val wasReportsCount = chat.chatStats.reportsCount + val nowReportsCount = chats[i].chatStats.reportsCount + val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0 + changeGroupReportsTagNoContentTag(by) + } + } + + private fun changeGroupReportsTagNoContentTag(by: Int = 0) { + if (by == 0 || contentTag != null) return + presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by + } + } fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return @@ -625,79 +898,33 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) - withContext(Dispatchers.Main) { - chatItems.addAndNotify(cItem) + withChats { + chatItems.addAndNotify(cItem, contentTag = null) } return cItem } fun removeLiveDummy() { - if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.removeLastAndNotify() - } - } - - private fun markItemsReadInCurrentChat(chatInfo: ChatInfo, itemIds: List? = null): Int { - val cInfo = chatInfo - var markedRead = 0 - if (chatId.value == cInfo.id) { - val items = chatItems.value - var i = items.lastIndex - val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() - val markedReadIds = mutableSetOf() - while (i >= 0) { - val item = items[i] - if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { - val newItem = item.withStatus(CIStatus.RcvRead()) - items[i] = newItem - if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { - items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( - deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) - ) - } - markedReadIds.add(item.id) - markedRead++ - if (itemIds != null) { - itemIdsFromRange.remove(item.id) - // already set all needed items as read, can finish the loop - if (itemIdsFromRange.isEmpty()) break - } + if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + withApi { + withChats { + chatItems.removeLastAndNotify(contentTag = null) } - i-- } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) - } - return markedRead - } - - fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounter(rhId, user, 1) - } - - fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounter(rhId, user, -by) - } - - private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } - if (i != -1) { - users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) } } - fun getChatItemIndexOrNull(cItem: ChatItem): Int? { - val reversedChatItems = chatItems.asReversed() + fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List): Int? { val index = reversedChatItems.indexOfFirst { it.id == cItem.id } return if (index != -1) index else null } // this function analyses "connected" events and assumes that each member will be there only once - fun getConnectedMemberNames(cItem: ChatItem): Pair> { + fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List): Pair> { var count = 0 val ns = mutableListOf() - var idx = getChatItemIndexOrNull(cItem) + var idx = getChatItemIndexOrNull(cItem, reversedChatItems) if (cItem.mergeCategory != null && idx != null) { - val reversedChatItems = chatItems.asReversed() while (idx < reversedChatItems.size) { val ci = reversedChatItems[idx] if (ci.mergeCategory != cItem.mergeCategory) break @@ -714,9 +941,8 @@ object ChatModel { // returns the index of the first item in the same merged group (the first hidden item) // and the previous visible item with another merge category - fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List): Pair { var i = ciIndex ?: return null to null - val reversedChatItems = chatItems.asReversed() val fst = reversedChatItems.lastIndex while (i < fst) { i++ @@ -729,8 +955,7 @@ object ChatModel { } // returns the previous member in the same merge group and the count of members in this group - fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { - val reversedChatItems = chatItems.asReversed() + fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List): Pair { var prevMember: GroupMember? = null val names: MutableSet = mutableSetOf() for (i in range) { @@ -754,19 +979,25 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = withId + withApi { + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = withId + } + } ModalManager.start.closeModals() ModalManager.end.closeModals() } } - fun dismissConnReqView(id: String) { + fun dismissConnReqView(id: String) = withApi { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = null + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = null + } // Close NewChatView ModalManager.start.closeModals() ModalManager.center.closeModals() @@ -977,6 +1208,8 @@ data class Chat( else -> false } + val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val id: String get() = chatInfo.id fun groupFeatureEnabled(feature: GroupFeature): Boolean = @@ -998,7 +1231,13 @@ data class Chat( } @Serializable - data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) + data class ChatStats( + val unreadCount: Int = 0, + // actual only via getChats() and getChat(.initial), otherwise, zero + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false + ) companion object { val sampleData = Chat( @@ -1189,7 +1428,19 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> false } -} + val chatTags: List? + get() = when (this) { + is Direct -> contact.chatTags + is Group -> groupInfo.chatTags + else -> null + } + + val contactCard: Boolean + get() = when (this) { + is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active + else -> false + } + } @Serializable sealed class NetworkStatus { @@ -1232,6 +1483,7 @@ data class Contact( val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + val chatTags: List, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1315,6 +1567,7 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, + chatTags = emptyList() ) } } @@ -1383,11 +1636,7 @@ data class Connection( } @Serializable -data class VersionRange(val minVersion: Int, val maxVersion: Int) { - - fun isCompatibleRange(vRange: VersionRange): Boolean = - this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion -} +data class VersionRange(val minVersion: Int, val maxVersion: Int) @Serializable data class SecurityCode(val securityCode: String, val verifiedAt: Instant) @@ -1476,6 +1725,8 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val chatTags: List, + override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1493,10 +1744,9 @@ data class GroupInfo ( ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } - override val displayName get() = groupProfile.displayName + override val displayName get() = localAlias.ifEmpty { groupProfile.displayName } override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image - override val localAlias get() = "" val isOwner: Boolean get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent @@ -1507,6 +1757,9 @@ data class GroupInfo ( val canAddMembers: Boolean get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive + val canModerate: Boolean + get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + companion object { val sampleData = GroupInfo( groupId = 1, @@ -1520,6 +1773,8 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + chatTags = emptyList(), + localAlias = "" ) } } @@ -1649,7 +1904,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } + GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { @@ -1700,13 +1955,19 @@ enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @SerialName("author") Author("author"), @SerialName("member") Member("member"), + @SerialName("moderator") Moderator("moderator"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); + companion object { + val selectableRoles: List = listOf(Observer, Member, Admin, Owner) + } + val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) + Moderator -> generalGetString(MR.strings.group_member_role_moderator) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) } @@ -2127,6 +2388,14 @@ data class ChatItem ( else -> true } + val isReport: Boolean get() = when (content) { + is CIContent.SndMsgContent, is CIContent.RcvMsgContent -> + content.msgContent is MsgContent.MCReport + else -> false + } + + val isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2343,8 +2612,8 @@ fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.addAndNotify(index: Int, elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) } +fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) } } fun MutableState>.add(elem: Chat) { @@ -2355,8 +2624,8 @@ fun MutableState>.add(elem: Chat) { fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate) // Adds item to chatItems and notifies a listener about newly added item -fun MutableState>.addAndNotify(elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) } +fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) } } fun MutableState>.addAll(index: Int, elems: List) { @@ -2385,7 +2654,8 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } } if (toRemove.isNotEmpty()) { - chatItemsChangesListener?.removed(toRemove, value) + chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value) } } @@ -2397,7 +2667,7 @@ fun MutableState>.removeAt(index: Int): Chat { return res } -fun MutableState>.removeLastAndNotify() { +fun MutableState>.removeLastAndNotify(contentTag: MsgContentTag?) { val removed: Triple value = SnapshotStateList().apply { addAll(value) @@ -2405,7 +2675,7 @@ fun MutableState>.removeLastAndNotify() { val rem = removeLast() removed = Triple(rem.id, remIndex, rem.isRcvNew) } - chatItemsChangesListener?.removed(listOf(removed), value) + chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value) } fun MutableState>.replaceAll(elems: List) { @@ -2419,7 +2689,8 @@ fun MutableState>.clear() { // Removes all chatItems and notifies a listener about it fun MutableState>.clearAndNotify() { value = SnapshotStateList() - chatItemsChangesListener?.cleared() + chatModel.chatsContext.chatItemsChangesListener?.cleared() + chatModel.reportsChatsContext.chatItemsChangesListener?.cleared() } fun State>.asReversed(): MutableList = value.asReversed() @@ -2547,7 +2818,7 @@ fun getTimestampDateText(t: Instant): String { val time = t.toLocalDateTime(tz).toJavaLocalDateTime() val weekday = time.format(DateTimeFormatter.ofPattern("EEE")) val dayMonthYear = time.format(DateTimeFormatter.ofPattern( - if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY") + if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy") ) return "$weekday, $dayMonthYear" @@ -2779,6 +3050,7 @@ sealed class CIForwardedFrom { @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), + @SerialName("internalMark") cidmInternalMark("internalMark"), @SerialName("broadcast") cidmBroadcast("broadcast"); } @@ -2980,6 +3252,14 @@ sealed class MsgReaction { companion object { val values: List get() = MREmojiChar.values().map(::Emoji) + val old: List get() = listOf( + MREmojiChar.ThumbsUp, + MREmojiChar.ThumbsDown, + MREmojiChar.Smile, + MREmojiChar.Sad, + MREmojiChar.Heart, + MREmojiChar.Launch + ).map(::Emoji) } } @@ -3026,9 +3306,11 @@ enum class MREmojiChar(val value: String) { @SerialName("👍") ThumbsUp("👍"), @SerialName("👎") ThumbsDown("👎"), @SerialName("😀") Smile("😀"), + @SerialName("😂") Laugh("😂"), @SerialName("😢") Sad("😢"), @SerialName("❤") Heart("❤"), - @SerialName("🚀") Launch("🚀"); + @SerialName("🚀") Launch("🚀"), + @SerialName("✅") Check("✅"); } @Serializable @@ -3304,15 +3586,22 @@ sealed class CIFileStatus { @Serializable sealed class FileError { @Serializable @SerialName("auth") class Auth: FileError() + @Serializable @SerialName("blocked") class Blocked(val server: String, val blockInfo: BlockingInfo): FileError() @Serializable @SerialName("noFile") class NoFile: FileError() @Serializable @SerialName("relay") class Relay(val srvError: SrvError): FileError() @Serializable @SerialName("other") class Other(val fileError: String): FileError() val errorInfo: String get() = when (this) { - is FileError.Auth -> generalGetString(MR.strings.file_error_auth) - is FileError.NoFile -> generalGetString(MR.strings.file_error_no_file) - is FileError.Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) - is FileError.Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + is Auth -> generalGetString(MR.strings.file_error_auth) + is Blocked -> generalGetString(MR.strings.file_error_blocked).format(blockInfo.reason.text) + is NoFile -> generalGetString(MR.strings.file_error_no_file) + is Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) + is Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + } + + val moreInfoButton: Pair? get() = when(this) { + is Blocked -> generalGetString(MR.strings.how_it_works) to contentModerationPostLink + else -> null } } @@ -3327,6 +3616,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -3403,6 +3693,10 @@ object MsgContentSerializer : KSerializer { element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) + element("MCReport", buildClassSerialDescriptor("MCReport") { + element("text") + element("reason") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -3433,6 +3727,10 @@ object MsgContentSerializer : KSerializer { MsgContent.MCVoice(text, duration) } "file" -> MsgContent.MCFile(text) + "report" -> { + val reason = Json.decodeFromString(json["reason"].toString()) + MsgContent.MCReport(text, reason) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -3481,12 +3779,29 @@ object MsgContentSerializer : KSerializer { put("type", "file") put("text", value.text) } + is MsgContent.MCReport -> + buildJsonObject { + put("type", "report") + put("text", value.text) + put("reason", json.encodeToJsonElement(value.reason)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) } } +@Serializable +enum class MsgContentTag { + @SerialName("text") Text, + @SerialName("link") Link, + @SerialName("image") Image, + @SerialName("video") Video, + @SerialName("voice") Voice, + @SerialName("file") File, + @SerialName("report") Report, +} + @Serializable class FormattedText(val text: String, val format: Format? = null) { // TODO make it dependent on simplexLinkMode preference @@ -3575,6 +3890,58 @@ enum class FormatColor(val color: String) { } } + +@Serializable(with = ReportReasonSerializer::class) +sealed class ReportReason { + @Serializable @SerialName("spam") object Spam: ReportReason() + @Serializable @SerialName("illegal") object Illegal: ReportReason() + @Serializable @SerialName("community") object Community: ReportReason() + @Serializable @SerialName("profile") object Profile: ReportReason() + @Serializable @SerialName("other") object Other: ReportReason() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() + + companion object { + val supportedReasons: List = listOf(Spam, Illegal, Community, Profile, Other) + } + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.report_reason_spam) + Illegal -> generalGetString(MR.strings.report_reason_illegal) + Community -> generalGetString(MR.strings.report_reason_community) + Profile -> generalGetString(MR.strings.report_reason_profile) + Other -> generalGetString(MR.strings.report_reason_other) + is Unknown -> type + } +} + +object ReportReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ReportReason { + return when (val value = decoder.decodeString()) { + "spam" -> ReportReason.Spam + "illegal" -> ReportReason.Illegal + "community" -> ReportReason.Community + "profile" -> ReportReason.Profile + "other" -> ReportReason.Other + else -> ReportReason.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: ReportReason) { + val stringValue = when (value) { + is ReportReason.Spam -> "spam" + is ReportReason.Illegal -> "illegal" + is ReportReason.Community -> "community" + is ReportReason.Profile -> "profile" + is ReportReason.Other -> "other" + is ReportReason.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + @Serializable class SndFileTransfer() {} @@ -3850,6 +4217,13 @@ sealed class ChatItemTTL: Comparable { } } +@Serializable +data class ChatTag( + val chatTagId: Long, + val chatTagText: String, + val chatTagEmoji: String? +) + @Serializable class ChatItemInfo( val itemVersions: List, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7e3d8597b4..f891d206a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -18,10 +18,13 @@ import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen +import chat.simplex.common.model.SMPErrorType.BLOCKED import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData @@ -46,11 +49,8 @@ import java.util.Date typealias ChatCtrl = Long -// currentChatVersion in core -const val CURRENT_CHAT_VERSION: Int = 2 - // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -val CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion = 2, maxVersion = CURRENT_CHAT_VERSION) +val CREATE_MEMBER_CONTACT_VERSION = 2 enum class CallOnLockScreen { DISABLE, @@ -80,6 +80,7 @@ class AppPreferences { if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) + val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) @@ -104,6 +105,7 @@ class AppPreferences { val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name) val simplexLinkMode: SharedPreference = SharedPreference( get = fun(): SimplexLinkMode { @@ -358,6 +360,7 @@ class AppPreferences { private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode" private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode" + private const val SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS = "CanAskToEnableNotifications" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" @@ -371,6 +374,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" + private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" @@ -624,6 +628,9 @@ object ChatController { val chats = apiGetChats(rhId) updateChats(chats) } + chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.activeChatTagFilter.value = null + chatModel.updateChatTags(rhId) } private fun startReceiver() { @@ -678,6 +685,8 @@ object ChatController { Log.d(TAG, "sendCmd: ${cmd.cmdType}") } val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) + // coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups) + interruptIfCancelled() val r = APIResponse.decodeStr(json) if (log) { Log.d(TAG, "sendCmd response type ${r.resp.responseType}") @@ -879,8 +888,18 @@ object ChatController { return emptyList() } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + private suspend fun apiGetChatTags(rh: Long?): List?{ + val userId = currentUserId("apiGetChatTags") + val r = sendCmd(rh, CC.ApiGetChatTags(userId)) + + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { @@ -891,6 +910,28 @@ object ChatController { return null } + suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { + val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List): Pair, List>? { + val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds)) + if (r is CR.TagsUpdated) return r.userTags to r.chatTags + Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiDeleteChatTag(rh: Long?, tagId: Long) = sendCommandOkResp(rh, CC.ApiDeleteChatTag(tagId)) + + suspend fun apiUpdateChatTag(rh: Long?, tagId: Long, tag: ChatTagData) = sendCommandOkResp(rh, CC.ApiUpdateChatTag(tagId, tag)) + + suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) @@ -939,6 +980,17 @@ object ChatController { } } + suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { + val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) + return when (r) { + is CR.NewChatItems -> r.chatItems + else -> { + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + null + } + } + } + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo @@ -968,6 +1020,7 @@ object ChatController { val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) when { r is CR.ChatItemUpdated -> return r.chatItem + r is CR.ChatItemNotChanged -> return r.chatItem r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), @@ -1362,6 +1415,15 @@ object ChatController { ) return null } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent + && r.chatError.agentError is AgentErrorType.SMP + && r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> { + showContentBlockedAlert( + generalGetString(MR.strings.connection_error_blocked), + generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), + ) + return null + } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP && r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> { @@ -1449,6 +1511,9 @@ object ChatController { withChats { clearChat(chat.remoteHostId, updatedChatInfo) } + withChats(MsgContentTag.Report) { + clearChat(chat.remoteHostId, updatedChatInfo) + } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() } @@ -1497,6 +1562,13 @@ object ChatController { return null } + suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? { + val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias)) + if (r is CR.GroupAliasUpdated) return r.toGroup + Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection @@ -2354,7 +2426,7 @@ object ChatController { val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { withChats { - if (chatModel.hasChat(rhId, contactRequest.id)) { + if (hasChat(rhId, contactRequest.id)) { updateChatInfo(rhId, cInfo) } else { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) @@ -2364,7 +2436,7 @@ object ChatController { ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) withChats { updateChatInfo(rhId, cInfo) @@ -2376,10 +2448,13 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.toMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.toMember) + } } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } @@ -2424,9 +2499,19 @@ object ChatController { if (active(r.user)) { withChats { addChatItem(rhId, cInfo, cItem) + if (cItem.isActiveReport) { + increaseGroupReportsCounter(rhId, cInfo.id) + } + } + withReportsChatsIfOpen { + if (cItem.isReport) { + addChatItem(rhId, cInfo, cItem) + } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(rhId, r.user) + withChats { + increaseUnreadCounter(rhId, r.user) + } } val file = cItem.file val mc = cItem.content.msgContent @@ -2449,6 +2534,11 @@ object ChatController { withChats { updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + } + } } } is CR.ChatItemUpdated -> @@ -2458,13 +2548,20 @@ object ChatController { withChats { updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } + withReportsChatsIfOpen { + if (r.reaction.chatReaction.chatItem.isReport) { + updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } + } } } is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(rhId, r.user) + withChats { + decreaseUnreadCounter(rhId, r.user) + } } } return @@ -2493,6 +2590,65 @@ object ChatController { upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } + withReportsChatsIfOpen { + if (cItem.isReport) { + if (toChatItem == null) { + removeChatItem(rhId, cInfo, cItem) + } else { + upsertChatItem(rhId, cInfo, toChatItem.chatItem) + } + } + } + } + } + is CR.GroupChatItemsDeleted -> { + if (!active(r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo) + withChats { + r.chatItemIDs.forEach { itemId -> + decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } + } + withReportsChatsIfOpen { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } } } is CR.ReceivedGroupInvitation -> { @@ -2558,30 +2714,45 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + } } is CR.LeftMember -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRole -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRoleUser -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberBlockedForAll -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { @@ -2954,6 +3125,11 @@ object ChatController { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { + if (cItem.isReport) { + upsertChatItem(rh, cInfo, cItem) + } + } } } @@ -2963,10 +3139,14 @@ object ChatController { val notify = { ntfManager.notifyMessageReceived(rh, user, cInfo, cItem) } if (!activeUser(rh, user)) { notify() - } else if (withChats { upsertChatItem(rh, cInfo, cItem) }) { - notify() - } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { - notify() + } else { + val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } } + if (createdChat) { + notify() + } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { + notify() + } } } @@ -3006,8 +3186,13 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - chatModel.chatItems.clearAndNotify() withChats { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } + withReportsChatsIfOpen { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } @@ -3117,8 +3302,12 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { init { this.set = { value -> - set(value) - _state.value = value + try { + set(value) + _state.value = value + } catch (e: Exception) { + Log.e(TAG, "Error saving settings: ${e.stackTraceToString()}") + } } } } @@ -3151,11 +3340,18 @@ sealed class CC { class TestStorageEncryption(val key: String): CC() class ApiSaveSettings(val settings: AppSettings): CC() class ApiGetSettings(val settings: AppSettings): CC() + class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() - class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiCreateChatTag(val tag: ChatTagData): CC() + class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() + class ApiDeleteChatTag(val tagId: Long): CC() + class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC() + class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() + class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() @@ -3222,6 +3418,7 @@ sealed class CC { class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() + class ApiSetGroupAlias(val groupId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC() class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC() @@ -3306,18 +3503,32 @@ sealed class CC { is TestStorageEncryption -> "/db test key $key" is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}" is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" + is ApiGetChatTags -> "/_get tags $userId" is ApiGetChats -> "/_get chats $userId pcc=on" - is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + is ApiGetChat -> { + val tag = if (contentTag == null) { + "" + } else { + " content=${contentTag.name.lowercase()}" + } + "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + } is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } + is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiDeleteChatTag -> "/_delete tag $tagId" + is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" + is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" is ApiCreateChatItems -> { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" @@ -3389,6 +3600,7 @@ sealed class CC { is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" + is ApiSetGroupAlias -> "/_set alias #$groupId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}" is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}" @@ -3470,11 +3682,18 @@ sealed class CC { is TestStorageEncryption -> "testStorageEncryption" is ApiSaveSettings -> "apiSaveSettings" is ApiGetSettings -> "apiGetSettings" + is ApiGetChatTags -> "apiGetChatTags" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" + is ApiCreateChatTag -> "apiCreateChatTag" + is ApiSetChatTags -> "apiSetChatTags" + is ApiDeleteChatTag -> "apiDeleteChatTag" + is ApiUpdateChatTag -> "apiUpdateChatTag" + is ApiReorderChatTags -> "apiReorderChatTags" is ApiCreateChatItems -> "apiCreateChatItems" + is ApiReportMessage -> "apiReportMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" @@ -3541,6 +3760,7 @@ sealed class CC { is ApiUpdateProfile -> "apiUpdateProfile" is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiSetContactAlias -> "apiSetContactAlias" + is ApiSetGroupAlias -> "apiSetGroupAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" is ApiSetUserUIThemes -> "apiSetUserUIThemes" is ApiSetChatUIThemes -> "apiSetChatUIThemes" @@ -3656,6 +3876,9 @@ sealed class ChatPagination { @Serializable class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +@Serializable +class ChatTagData(val emoji: String?, val text: String) + @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) @@ -3756,7 +3979,7 @@ data class ServerOperatorConditionsDetail( @Serializable() sealed class ConditionsAcceptance { - @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?, val autoAccepted: Boolean) : ConditionsAcceptance() @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() val conditionsAccepted: Boolean @@ -3800,7 +4023,7 @@ data class ServerOperator( tradeName = "SimpleX Chat", legalName = "SimpleX Chat Ltd", serverDomains = listOf("simplex.im"), - conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) @@ -3882,7 +4105,7 @@ data class UserOperatorServers( tradeName = "", legalName = null, serverDomains = emptyList(), - conditionsAcceptance = ConditionsAcceptance.Accepted(null), + conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) @@ -5389,6 +5612,7 @@ sealed class CR { @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() + @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @@ -5415,6 +5639,7 @@ sealed class CR { @Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() + @Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List, val chatTags: List): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() @@ -5430,6 +5655,7 @@ sealed class CR { @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR() @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR() @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() @@ -5462,6 +5688,7 @@ sealed class CR { @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("reactionMembers") class ReactionMembers(val user: UserRef, val memberReactions: List): CR() @Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List, val byUser: Boolean): CR() + @Serializable @SerialName("groupChatItemsDeleted") class GroupChatItemsDeleted(val user: UserRef, val groupInfo: GroupInfo, val chatItemIDs: List, val byUser: Boolean, val member_: GroupMember?): CR() @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -5573,6 +5800,7 @@ sealed class CR { is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" + is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" is ServerOperatorConditions -> "serverOperatorConditions" @@ -5599,6 +5827,7 @@ sealed class CR { is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" + is TagsUpdated -> "tagsUpdated" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionUserChanged -> "ConnectionUserChanged" @@ -5614,6 +5843,7 @@ sealed class CR { is UserProfileUpdated -> "userProfileUpdated" is UserPrivacy -> "userPrivacy" is ContactAliasUpdated -> "contactAliasUpdated" + is GroupAliasUpdated -> "groupAliasUpdated" is ConnectionAliasUpdated -> "connectionAliasUpdated" is ContactPrefsUpdated -> "contactPrefsUpdated" is UserContactLink -> "userContactLink" @@ -5644,6 +5874,7 @@ sealed class CR { is ChatItemReaction -> "chatItemReaction" is ReactionMembers -> "reactionMembers" is ChatItemsDeleted -> "chatItemsDeleted" + is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" @@ -5746,7 +5977,8 @@ sealed class CR { is ChatRunning -> noDetails() is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) - is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") + is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}") + is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" @@ -5773,6 +6005,7 @@ sealed class CR { is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") + is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}") is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) @@ -5788,6 +6021,7 @@ sealed class CR { is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) is UserPrivacy -> withUser(user, json.encodeToString(updatedUser)) is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup)) is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is UserContactLink -> withUser(user, contactLink.responseDetails) @@ -5818,6 +6052,7 @@ sealed class CR { is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ReactionMembers -> withUser(user, "memberReactions: ${json.encodeToString(memberReactions)}") is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser") + is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") @@ -6547,6 +6782,7 @@ sealed class BrokerErrorType { @Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType() } +// ProtocolErrorType @Serializable sealed class SMPErrorType { val string: String get() = when (this) { @@ -6555,9 +6791,10 @@ sealed class SMPErrorType { is CMD -> "CMD ${cmdErr.string}" is PROXY -> "PROXY ${proxyErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is CRYPTO -> "CRYPTO" is QUOTA -> "QUOTA" - is STORE -> "STORE ${storeErr}" + is STORE -> "STORE $storeErr" is NO_MSG -> "NO_MSG" is LARGE_MSG -> "LARGE_MSG" is EXPIRED -> "EXPIRED" @@ -6568,6 +6805,7 @@ sealed class SMPErrorType { @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): SMPErrorType() @Serializable @SerialName("PROXY") class PROXY(val proxyErr: ProxyError): SMPErrorType() @Serializable @SerialName("AUTH") class AUTH: SMPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): SMPErrorType() @Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType() @Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType() @Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType() @@ -6591,6 +6829,22 @@ sealed class ProxyError { @Serializable @SerialName("NO_SESSION") class NO_SESSION: ProxyError() } +@Serializable +data class BlockingInfo( + val reason: BlockingReason +) + +@Serializable +enum class BlockingReason { + @SerialName("spam") Spam, + @SerialName("content") Content; + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.blocking_reason_spam) + Content -> generalGetString(MR.strings.blocking_reason_content) + } +} + @Serializable sealed class ProtocolCommandError { val string: String get() = when (this) { @@ -6666,6 +6920,7 @@ sealed class XFTPErrorType { is SESSION -> "SESSION" is CMD -> "CMD ${cmdErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is SIZE -> "SIZE" is QUOTA -> "QUOTA" is DIGEST -> "DIGEST" @@ -6681,6 +6936,7 @@ sealed class XFTPErrorType { @Serializable @SerialName("SESSION") object SESSION: XFTPErrorType() @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): XFTPErrorType() @Serializable @SerialName("AUTH") object AUTH: XFTPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): XFTPErrorType() @Serializable @SerialName("SIZE") object SIZE: XFTPErrorType() @Serializable @SerialName("QUOTA") object QUOTA: XFTPErrorType() @Serializable @SerialName("DIGEST") object DIGEST: XFTPErrorType() @@ -6830,6 +7086,13 @@ enum class NotificationsMode() { } } +@Serializable +enum class PrivacyChatListOpenLinksMode { + @SerialName("yes") YES, + @SerialName("no") NO, + @SerialName("ask") ASK +} + @Serializable data class AppSettings( var networkConfig: NetCfg? = null, @@ -6838,6 +7101,7 @@ data class AppSettings( var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, var privacyLinkPreviews: Boolean? = null, + var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null, var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, var privacyProtectScreen: Boolean? = null, @@ -6873,6 +7137,7 @@ data class AppSettings( if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen } @@ -6919,6 +7184,7 @@ data class AppSettings( privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) } privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } privacyProtectScreen?.let { def.privacyProtectScreen.set(it) } @@ -6955,6 +7221,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, privacyProtectScreen = false, @@ -6992,6 +7259,7 @@ data class AppSettings( privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(), privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), privacyProtectScreen = def.privacyProtectScreen.get(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index e9fc8c97f9..0a4f670fe0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -3,7 +3,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import com.charleskorn.kaml.* import kotlinx.serialization.encodeToString @@ -11,6 +11,8 @@ import java.io.* import java.net.URI import java.net.URLDecoder import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.StandardCopyOption expect val dataDir: File expect val tmpDir: File @@ -20,6 +22,7 @@ expect val wallpapersDir: File expect val coreTmpDir: File expect val dbAbsolutePrefixPath: String expect val preferencesDir: File +expect val preferencesTmpDir: File expect val chatDatabaseFileName: String expect val agentDatabaseFileName: String @@ -142,16 +145,23 @@ fun readThemeOverrides(): List { } } +private const val lock = "themesWriter" + fun writeThemeOverrides(overrides: List): Boolean = - try { - File(getPreferenceFilePath("themes.yaml")).outputStream().use { - val string = yaml.encodeToString(ThemesFile(themes = overrides)) - it.bufferedWriter().use { it.write(string) } + synchronized(lock) { + try { + val themesFile = File(getPreferenceFilePath("themes.yaml")) + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + val string = yaml.encodeToString(ThemesFile(themes = overrides)) + tmpFile.bufferedWriter().use { it.write(string) } + themesFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), themesFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + true + } catch (e: Exception) { + Log.e(TAG, "Error writing themes file: ${e.stackTraceToString()}") + false } - true - } catch (e: Throwable) { - Log.e(TAG, "Error while writing themes file: ${e.stackTraceToString()}") - false } private fun fileReady(file: CIFile, filePath: String) = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index b4e823bd45..e6d4514875 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, // by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here // maxSize (at least maxHeight) is needed for blur on appBars to work correctly @@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, content: LazyListScope.() -> Unit ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index a5293b6a24..07f2b678cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -102,7 +102,9 @@ object ThemeManager { } fun applyTheme(theme: String) { - appPrefs.currentTheme.set(theme) + if (appPrefs.currentTheme.get() != theme) { + appPrefs.currentTheme.set(theme) + } CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) platform.androidSetNightModeIfSupported() val c = CurrentColors.value.colors diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt index 5265f3187b..a049230f27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt @@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable -fun SplashView() { +fun SplashView(nonTransparent: Boolean = false) { Surface( Modifier .fillMaxSize(), - color = MaterialTheme.colors.background, + color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background, contentColor = LocalContentColor.current ) { // Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 6a6db0da85..67fae65897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -26,6 +26,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout import chat.simplex.common.views.chatlist.NavigationBarBackground +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -88,7 +89,7 @@ fun TerminalLayout( .background(MaterialTheme.colors.background) ) { Divider() - Box(Modifier.padding(horizontal = 8.dp)) { + Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { SendMsgView( composeState = composeState, showVoiceRecordIcon = false, @@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State) { } } LazyColumnWithScrollBar ( - reverseLayout = true, + state = listState, contentPadding = PaddingValues( top = topPaddingToContent(false), bottom = composeViewHeight.value ), - state = listState, + reverseLayout = true, additionalBarOffset = composeViewHeight ) { items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index ed661245a3..2b3cf773cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -36,6 +36,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -697,13 +698,19 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(contact.profile.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( @@ -711,7 +718,8 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } @@ -724,6 +732,7 @@ fun LocalAliasEditor( center: Boolean = true, leadingIcon: Boolean = false, focus: Boolean = false, + isContact: Boolean = true, updateValue: (String) -> Unit ) { val state = remember(chatId) { @@ -740,7 +749,7 @@ fun LocalAliasEditor( state, { Text( - generalGetString(MR.strings.text_field_set_contact_placeholder), + generalGetString(if (isContact) MR.strings.text_field_set_contact_placeholder else MR.strings.text_field_set_chat_placeholder), textAlign = if (center) TextAlign.Center else TextAlign.Start, color = MaterialTheme.colors.secondary ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index be09c04ec1..6419aa884d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage( rhId: Long?, chatType: ChatType, apiId: Long, - itemId: Long + itemId: Long, + contentTag: MsgContentTag?, ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } @@ -24,30 +25,37 @@ suspend fun apiLoadMessages( rhId: Long?, chatType: ChatType, apiId: Long, + contentTag: MsgContentTag?, pagination: ChatPagination, - chatState: ActiveChatState, search: String = "", visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope // 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 if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) || !isActive) return@coroutineScope + val chatState = chatModel.chatStateForContent(contentTag) val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState - val oldItems = chatModel.chatItems.value + val oldItems = chatModel.chatItemsForContent(contentTag).value val newItems = SnapshotStateList() when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - withChats { - if (chatModel.getChat(chat.id) == null) { - addChat(chat) + if (contentTag == null) { + // update main chats, not content tagged + withChats { + if (getChat(chat.id) == null) { + addChat(chat) + } else { + updateChatInfo(chat.remoteHostId, chat.chatInfo) + updateChatStats(chat.remoteHostId, chat.id, chat.chatStats) + } } } - withContext(Dispatchers.Main) { - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) + withChats(contentTag) { + chatItemStatuses.clear() + chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -70,8 +78,8 @@ suspend fun apiLoadMessages( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats(contentTag) { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -89,8 +97,8 @@ suspend fun apiLoadMessages( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats(contentTag) { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -104,8 +112,8 @@ suspend fun apiLoadMessages( val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats(contentTag) { + chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -119,8 +127,8 @@ suspend fun apiLoadMessages( newItems.addAll(oldItems) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats(contentTag) { + chatItems.replaceAll(newItems) unreadAfterNewestLoaded.value = 0 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index fda5c35e01..d318cf05fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -240,14 +239,13 @@ data class ActiveChatState ( } } -fun visibleItemIndexesNonReversed(mergedItems: State, listState: LazyListState): IntRange { +fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { val zero = 0 .. 0 if (listState.layoutInfo.totalItemsCount == 0) return zero val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() if (newest == null || oldest == null) return zero - val size = chatModel.chatItems.value.size - val range = size - oldest .. size - newest + val range = reversedItemsSize - oldest .. reversedItemsSize - newest if (range.first < 0 || range.last < 0) return zero // visible items mapped to their underlying data structure which is chatModel.chatItems diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 3c0f1f7769..7713a2399f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -32,6 +32,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -56,10 +57,17 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts -fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) -> Unit) { - val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } +fun ChatView( + staleChatId: State, + reportsView: Boolean, + scrollToItemId: MutableState = remember { mutableStateOf(null) }, + onComposed: suspend (chatId: String) -> Unit +) { val showSearch = rememberSaveable { mutableStateOf(false) } + // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." + val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } + val activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -68,6 +76,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ModalManager.end.closeModals() } } else { + val groupReports = remember { derivedStateOf { + val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 + GroupReports(reportsCount, reportsView) } + } + val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -93,7 +106,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - .distinctUntilChanged() .filterNotNull() .collect { chatId -> - markUnreadChatAsRead(chatId) + if (!groupReports.value.reportsView) { + markUnreadChatAsRead(chatId) + } showSearch.value = false searchText.value = "" selectedChatItems.value = null @@ -106,20 +121,34 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == activeChatInfo.value?.id }?.chatStats?.unreadCount ?: 0 + chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) { + CompositionLocalProvider( + LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalContentTag provides groupReports.value.contentTag + ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { + var groupMembersJob: Job = remember { Job() } val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { + val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> + if (searchText.value == value) return@onSearchValueChanged + val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged + if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + withBGApi { + apiFindMessages(c, value, groupReports.value.toContentTag()) + searchText.value = value + } + } ChatLayout( remoteHostId = remoteHostId, chatInfo = activeChatInfo, + reversedChatItems = reversedChatItems, unreadCount, composeState, composeView = { @@ -148,7 +177,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } else { SelectedItemsBottomToolbar( - chatItems = remember { chatModel.chatItems }.value, + reversedChatItems = reversedChatItems, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -209,6 +238,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ) } }, + groupReports, + scrollToItemId, attachmentOption, attachmentBottomSheetState, searchText, @@ -219,8 +250,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -228,7 +259,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - return@ChatLayout } hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { // The idea is to preload information before showing a modal because large groups can take time to load all members var preloadedContactInfo: Pair? = null var preloadedCode: String? = null @@ -240,6 +272,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) } + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> val chatInfo = remember { activeChatInfo }.value if (chatInfo is ChatInfo.Direct) { @@ -261,7 +295,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, { link = it preloadedLink = it }, close, { showSearch.value = true }) @@ -273,9 +307,21 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, + showGroupReports = { + val info = activeChatInfo.value ?: return@ChatLayout + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + scope.launch { + showGroupReportsView(staleChatId, scrollToItemId, info) + } + }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { @@ -285,7 +331,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - member to null } setGroupMembers(chatRh, groupInfo, chatModel) - ModalManager.end.closeModals() + if (!isActive) return@launch + + if (!groupReports.value.reportsView) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) @@ -293,16 +343,16 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - loadMessages = { chatId, pagination, chatState, visibleItemIndexes -> + loadMessages = { chatId, pagination, visibleItemIndexes -> val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, chatState, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -333,6 +383,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } else { removeChatItem(chatRh, chatInfo, deletedChatItem) } + val deletedItem = deleted.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + withReportsChatsIfOpen { + if (deletedChatItem.isReport) { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) + } + } } } } @@ -430,7 +493,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatModel.getChat(chatId) }, findModelMember = { memberId -> - chatModel.groupMembers.find { it.id == memberId } + chatModel.groupMembers.value.find { it.id == memberId } }, setReaction = { cInfo, cItem, add, reaction -> withBGApi { @@ -446,22 +509,31 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { updateChatItem(cInfo, updatedCI) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, updatedCI) + } + } } } }, showItemDetails = { cInfo, cItem -> - suspend fun loadChatItemInfo(): ChatItemInfo? { + suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) + if (!isActive) return@coroutineScope null } } - return ciInfo + ciInfo } - withBGApi { - var initialCiInfo = loadChatItemInfo() ?: return@withBGApi - ModalManager.end.closeModals() + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { + var initialCiInfo = loadChatItemInfo() ?: return@launch + if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get())) @@ -496,7 +568,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo, itemsIds) + markChatItemsRead(chatRh, chatInfo.id, itemsIds) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( @@ -506,6 +578,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - itemsIds ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id, itemsIds) + } } }, markChatRead = { @@ -513,7 +588,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo) + markChatItemsRead(chatRh, chatInfo.id) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatRead( @@ -522,18 +597,13 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatInfo.apiId ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id) + } } }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, - onSearchValueChanged = { value -> - if (searchText.value == value) return@ChatLayout - val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout - if (chatModel.chatId.value != chatInfo.id) return@ChatLayout - withBGApi { - apiFindMessages(c, value) - searchText.value = value - } - }, + onSearchValueChanged = onSearchValueChanged, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -549,7 +619,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } is ChatInfo.InvalidJSON -> { @@ -560,7 +632,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } else -> {} @@ -586,9 +660,12 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( remoteHostId: State, chatInfo: State, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), + groupReports: State, + scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, searchValue: State, @@ -597,8 +674,9 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, + showGroupReports: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -657,7 +735,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -670,8 +748,8 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, + useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -679,29 +757,90 @@ fun ChatLayout( } } } - Box( - Modifier - .layoutId(CHAT_COMPOSE_LAYOUT_ID) - .align(Alignment.BottomCenter) - .imePadding() - .navigationBarsPadding() - .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) - ) { - composeView() + if (groupReports.value.reportsView) { + Column( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .imePadding() + ) { + AnimatedVisibility(selectedChatItems.value != null) { + if (chatInfo != null) { + SelectedItemsBottomToolbar( + reversedChatItems = reversedChatItems, + selectedChatItems = selectedChatItems, + chatInfo = chatInfo, + deleteItems = { _ -> + val itemIds = selectedChatItems.value + val questionText = generalGetString(MR.strings.delete_messages_cannot_be_undone_warning) + if (itemIds != null) { + deleteMessagesAlertDialog(itemIds.sorted(), questionText = questionText, forAll = false, deleteMessages = { ids, _ -> + deleteMessages(remoteHostId, chatInfo, ids, false, moderate = false) { + selectedChatItems.value = null + } + }) + } + }, + moderateItems = {}, + forwardItems = {} + ) + } + } + if (oneHandUI.value) { + // That's placeholder to take some space for bottom app bar in oneHandUI + Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier)) + } + } + } else { + Box( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .imePadding() + .navigationBarsPadding() + .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) + ) { + composeView() + } } } if (oneHandUI.value && chatBottomBar.value) { - StatusBarBackground() + if (groupReports.value.showBar) { + ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + } else { + StatusBarBackground() + } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - Box(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + if (groupReports.value.reportsView) { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) + } + } + } + } else { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + if (chatInfo != null) { + ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) } - } else { - SelectedItemsTopToolbar(selectedChatItems) } } } @@ -712,6 +851,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, + groupReports: State, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -733,7 +873,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid) { + if (appPlatform.isAndroid && !groupReports.value.reportsView) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -927,25 +1067,65 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } } +@Composable +private fun ReportedCountToolbar( + groupReports: State, + withStatusBar: Boolean, + showGroupReports: () -> Unit +) { + Box { + val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp + Row( + Modifier + .fillMaxWidth() + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showGroupReports) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + Spacer(Modifier.width(4.dp)) + val reports = groupReports.value.reportsCount + Text( + if (reports == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reports) + }, + style = MaterialTheme.typography.button + ) + } + Divider(Modifier.align(Alignment.BottomStart)) + } +} + @Composable private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } +/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +private var reportsListState: LazyListState? = null + @Composable fun BoxScope.ChatItemsList( remoteHostId: Long?, chatInfo: ChatInfo, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + groupReports: State, + scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -970,10 +1150,9 @@ fun BoxScope.ChatItemsList( showViaProxy: Boolean ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatState) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears * */ @@ -982,23 +1161,32 @@ fun BoxScope.ChatItemsList( ) val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index <= 0) { + val reportsState = reportsListState + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0) { LazyListState(0, 0) } else { LazyListState(index + 1, -maxHeightForList.value) } }) + SaveReportsStateOnDispose(groupReports, listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false try { loadingMoreItems.value = true - loadMessages(chatId, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } finally { loadingMoreItems.value = false @@ -1011,21 +1199,33 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val highlightedItems = remember { mutableStateOf(setOf()) } val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } - - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) - SmallScrollOnNewMessage(listState, chatModel.chatItems) + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (groupReports.value.reportsView) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) } + if (!groupReports.value.reportsView) { + LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() + } + scrollToItem(it) + scrollToItemId.value = null } + } + } + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) + SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( always = { - chatModel.chatItemsChangesListener = recalculateChatStatePositions(chatModel.chatState) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.chatItemsChangesListener = null + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) } ) @@ -1047,7 +1247,7 @@ fun BoxScope.ChatItemsList( LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { val provider = { - providerForGallery(chatModel.chatItems.value, cItem.id) { indexInReversed -> + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> itemScope.launch { listState.value.scrollToItem( min(reversedChatItems.value.lastIndex, indexInReversed + 1), @@ -1072,7 +1272,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1081,7 +1281,7 @@ fun BoxScope.ChatItemsList( val dismissState = rememberDismissState(initialValue = DismissValue.Default) { if (it == DismissValue.DismissedToStart) { itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) { + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -1150,7 +1350,7 @@ fun BoxScope.ChatItemsList( val rangeValue = range.value val (prevMember, memCount) = if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue) + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) } else { null to 1 } @@ -1258,7 +1458,7 @@ fun BoxScope.ChatItemsList( if (selectionVisible) { Box(Modifier.matchParentSize().clickable { val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems) + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) }) } } @@ -1272,12 +1472,13 @@ fun BoxScope.ChatItemsList( LazyColumnWithScrollBar( Modifier.align(Alignment.BottomCenter), state = listState.value, - reverseLayout = true, contentPadding = PaddingValues( - top = topPaddingToContent(true), + top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), bottom = composeViewHeight.value ), + reverseLayout = true, additionalBarOffset = composeViewHeight, + additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1321,8 +1522,8 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState) + FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -1342,14 +1543,14 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { LaunchedEffect(remoteHostId, chatInfo.id) { try { loadingMoreItems.value = true - if (chatModel.chatState.totalAfter.value <= 0) return@LaunchedEffect + if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect delay(500) withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } } finally { loadingMoreItems.value = false @@ -1358,20 +1559,20 @@ private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: } @Composable -private fun SmallScrollOnNewMessage(listState: State, chatItems: State>) { +private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } LaunchedEffect(Unit) { - var lastTotalItems = listState.value.layoutInfo.totalItemsCount - var lastItemId = chatItems.value.lastOrNull()?.id + var prevTotalItems = listState.value.layoutInfo.totalItemsCount + var newestItemId = reversedChatItems.value.firstOrNull()?.id snapshotFlow { listState.value.layoutInfo.totalItemsCount } .distinctUntilChanged() .drop(1) .collect { - val diff = listState.value.layoutInfo.totalItemsCount - lastTotalItems - val sameLastItem = lastItemId == chatItems.value.lastOrNull()?.id - lastTotalItems = listState.value.layoutInfo.totalItemsCount - lastItemId = chatItems.value.lastOrNull()?.id - if (diff < 1 || diff > 2 || sameLastItem) { + val diff = listState.value.layoutInfo.totalItemsCount - prevTotalItems + val sameNewestItem = newestItemId == reversedChatItems.value.firstOrNull()?.id + prevTotalItems = listState.value.layoutInfo.totalItemsCount + newestItemId = reversedChatItems.value.firstOrNull()?.id + if (diff < 1 || diff > 2 || sameNewestItem) { return@collect } try { @@ -1382,7 +1583,7 @@ private fun SmallScrollOnNewMessage(listState: State, chatItems: } } catch (e: CancellationException) { /** - * When you tap and hold a finger on a lazy column with chatItems, and then you receive a message, + * When you tap and hold a finger on a lazy column with reversedChatItems, and then you receive a message, * this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll. * Which breaks auto-scrolling to bottom. So just ignoring the exception * */ @@ -1422,11 +1623,12 @@ fun BoxScope.FloatingButtons( maxHeight: State, composeViewHeight: State, searchValue: State, + groupReports: State, markChatRead: () -> Unit, listState: State ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1472,7 +1674,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), topUnreadCount, animatedScrollingInProgress, onClick = { @@ -1494,7 +1696,7 @@ fun BoxScope.FloatingButtons( DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(true)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1511,6 +1713,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1521,8 +1724,8 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, mergedItems, listState, remaining, loadItems) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) } @Composable @@ -1530,6 +1733,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1542,9 +1746,9 @@ private fun PreloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = chatModel.chatItems.value - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining && items.size >= ChatPagination.INITIAL_COUNT) { - lastIndexToLoadFrom = items.lastIndex + val items = reversedChatItems.value + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { + lastIndexToLoadFrom = 0 } if (allowLoad.value && lastIndexToLoadFrom != null) { items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id @@ -1556,10 +1760,10 @@ private fun PreloadItemsBefore( .filter { !ignoreLoadingRequests.value.contains(it) } .collect { loadFromItemId -> withBGApi { - val sizeWas = chatModel.chatItems.value.size - val firstItemIdWas = chatModel.chatItems.value.firstOrNull()?.id + val sizeWas = reversedChatItems.value.size + val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == chatModel.chatItems.value.size && firstItemIdWas == chatModel.chatItems.value.firstOrNull()?.id) { + if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) } } @@ -1571,6 +1775,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1591,12 +1796,12 @@ private fun PreloadItemsAfter( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .map { firstVisibleIndex -> - val items = chatModel.chatItems.value + val items = reversedChatItems.value val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { - items.getOrNull(items.lastIndex - split.indexRangeInReversed.first)?.id + items.getOrNull(split.indexRangeInReversed.first)?.id } else { null } @@ -1645,13 +1850,14 @@ private fun TopEndFloatingButton( } @Composable -fun topPaddingToContent(chatView: Boolean): Dp { +fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): Dp { val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val reportsPadding = if (additionalTopBar) AppBarHeight * fontSizeSqrtMultiplier else 0.dp return if (oneHandUI.value && (!chatView || chatBottomBar.value)) { - WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } else { - AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } } @@ -1660,12 +1866,13 @@ private fun FloatingDate( modifier: Modifier, mergedItems: State, listState: State, + groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1749,6 +1956,15 @@ private fun FloatingDate( } } +@Composable +private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { + DisposableEffect(Unit) { + onDispose { + reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + } + } +} + @Composable private fun DownloadFilesButton( forwardConfirmation: ForwardConfirmation.FilesNotAccepted, @@ -1875,7 +2091,7 @@ private fun scrollToItem( reversedChatItems: State>, mergedItems: State, listState: State, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, ): (Long) -> Unit = { itemId: Long -> withApi { try { @@ -1889,8 +2105,8 @@ private fun scrollToItem( val pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2) val oldSize = reversedChatItems.value.size withContext(Dispatchers.Default) { - loadMessages(chatInfo.value.id, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } var repeatsLeft = 50 @@ -1921,14 +2137,18 @@ private fun findQuotedItemFromItem( rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit + scrollToItem: (Long) -> Unit, + contentTag: MsgContentTag? ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { - val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) + val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) if (item != null) { withChats { updateChatItem(chatInfo.value, item) } + withReportsChatsIfOpen { + updateChatItem(chatInfo.value, item) + } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) } else { @@ -2025,18 +2245,24 @@ private fun SelectedChatItem( ) } -private fun selectUnselectChatItem(select: Boolean, ci: ChatItem, revealed: State, selectedChatItems: MutableState?>) { +private fun selectUnselectChatItem( + select: Boolean, + ci: ChatItem, + revealed: State, + selectedChatItems: MutableState?>, + reversedChatItems: State> +) { val itemIds = mutableSetOf() if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(ci) + val currIndex = chatModel.getChatItemIndexOrNull(ci, reversedChatItems.value) val ciCategory = ci.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems.value) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { - val reversedChatItems = chatModel.chatItems.asReversed() + val reversed = reversedChatItems.value for (i in range) { - itemIds.add(reversedChatItems[i].id) + itemIds.add(reversed[i].id) } } else { itemIds.add(ci.id) @@ -2084,6 +2310,22 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List?, - backgroundGraphicsLayer: GraphicsLayer? + backgroundGraphicsLayer: GraphicsLayer?, + drawWallpaper: Boolean ): Modifier { val wallpaperImage = wallpaper.type.image val wallpaperType = wallpaper.type - val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) + val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base) return this - .then(if (wallpaperImage != null) + .then(if (wallpaperImage != null && drawWallpaper) Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) } else Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } } @@ -2284,7 +2528,7 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf private fun forwardContent(chatItemsIds: List, chatInfo: ChatInfo) { chatModel.chatId.value = null chatModel.sharedContent.value = SharedContent.Forward( - chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) }, + chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) }, chatInfo ) } @@ -2421,9 +2665,12 @@ fun PreviewChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2432,8 +2679,9 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2494,9 +2742,12 @@ fun PreviewGroupChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2505,8 +2756,9 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 3a63cf508e..c413e06599 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged @@ -51,6 +52,7 @@ sealed class ComposeContextItem { @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo): ComposeContextItem() + @Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem() } @Serializable @@ -89,13 +91,28 @@ data class ComposeState( is ComposeContextItem.ForwardingItems -> true else -> false } + val reporting: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> true + else -> false + } + val submittingValidReport: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> { + when (contextItem.reason) { + is ReportReason.Other -> message.isNotEmpty() + else -> true + } + } + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } @@ -119,7 +136,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || forwarding || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress || reporting) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -136,6 +153,12 @@ data class ComposeState( is ComposePreview.FilePreview -> true } + val placeholder: String + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> contextItem.reason.text + else -> generalGetString(MR.strings.compose_message_placeholder) + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -170,6 +193,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) + is MsgContent.MCReport -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -483,10 +507,24 @@ fun ComposeView( is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } + suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { + val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) + if (cItems != null) { + withChats { + cItems.forEach { chatItem -> + addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + } + } + } + + return cItems?.map { it.chatItem } + } + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) @@ -552,6 +590,8 @@ fun ComposeView( } else if (liveMessage != null && liveMessage.sent) { val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) sent = if (updatedMessage != null) listOf(updatedMessage) else null + } else if (cs.contextItem is ComposeContextItem.ReportedItem) { + sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -570,7 +610,7 @@ fun ComposeView( if (remoteHost == null) saveAnimImage(it.uri) else CryptoFile.desktopPlain(it.uri) is UploadContent.Video -> - if (remoteHost == null) saveFileFromUri(it.uri) + if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video") else CryptoFile.desktopPlain(it.uri) } if (file != null) { @@ -796,7 +836,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable } + val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } @@ -833,14 +873,33 @@ fun ComposeView( @Composable fun MsgNotAllowedView(reason: String, icon: Painter) { - val color = MaterialTheme.appColors.receivedMessage - Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, tint = MaterialTheme.colors.secondary) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Text(reason, fontStyle = FontStyle.Italic) } } + @Composable + fun ReportReasonView(reason: ReportReason) { + val reportText = when (reason) { + is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam) + is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal) + is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile) + is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community) + is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other) + is ReportReason.Unknown -> null // should never happen + } + + if (reportText != null) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp) + } + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -854,6 +913,9 @@ fun ComposeView( is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -891,6 +953,10 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val ctx = composeState.value.contextItem + if (ctx is ComposeContextItem.ReportedItem) { + ReportReasonView(ctx.reason) + } val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) @@ -918,154 +984,153 @@ fun ComposeView( } } } - Box(Modifier.background(MaterialTheme.colors.background)) { - Divider() - Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { + Divider() + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + val attachmentEnabled = + !composeState.value.attachmentDisabled + && sendMsgEnabled.value + && userCanSend.value + && !isGroupAndProhibitedFiles + && !nextSendGrpInv.value + IconButton( + attachmentClicked, + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + enabled = attachmentEnabled + ) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) ) } - } else { - showChooseAttachment - } - val attachmentEnabled = - !composeState.value.attachmentDisabled - && sendMsgEnabled.value - && userCanSend.value - && !isGroupAndProhibitedFiles - && !nextSendGrpInv.value - IconButton( - attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), - enabled = attachmentEnabled - ) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { + if (!chat.chatInfo.userCanSend) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val sendButtonColor = + if (chat.chatInfo.incognito) + if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) + else MaterialTheme.colors.primary + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), + nextSendGrpInv = nextSendGrpInv.value, + needToAllowVoiceToContact, + allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, + sendButtonColor = sendButtonColor, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + placeholder = composeState.value.placeholder, + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } - - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - val sendButtonColor = - if (chat.chatInfo.incognito) - if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) - else MaterialTheme.colors.primary - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - sendMsgEnabled = sendMsgEnabled.value, - sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), - nextSendGrpInv = nextSendGrpInv.value, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, - sendButtonColor = sendButtonColor, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - editPrevMessage = ::editPrevMessage, - onFilesPasted = { composeState.onFilesAttached(it) }, - onMessageChange = ::onMessageChange, - textStyle = textStyle - ) } } - } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 5850f0b7ec..1657a1f0b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ fun ContextItemView( contextIcon: Painter, showSender: Boolean = true, chatType: ChatType, + contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { val sentColor = MaterialTheme.appColors.sentMessage @@ -85,7 +87,6 @@ fun ContextItemView( Row( Modifier - .padding(top = 8.dp) .background(if (sent) sentColor else receivedColor), verticalAlignment = Alignment.CenterVertically ) { @@ -103,8 +104,8 @@ fun ContextItemView( .height(20.dp) .width(20.dp), contentDescription = stringResource(MR.strings.icon_descr_context), - tint = MaterialTheme.colors.secondary, - ) + tint = contextIconColor, + ) if (contextItems.count() == 1) { val contextItem = contextItems[0] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 838398c503..e449831ee0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -21,11 +21,10 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>) { +fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>, onTop: Boolean) { val onBackClicked = { selectedChatItems.value = null } BackHandler(onBack = onBackClicked) val count = selectedChatItems.value?.size ?: 0 - val oneHandUI = remember { appPrefs.oneHandUI.state } DefaultAppBar( navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, title = { @@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> ) }, onTitleClick = null, - onTop = !oneHandUI.value, + onTop = onTop, onSearchValueChanged = {}, ) } @@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> @Composable fun SelectedItemsBottomToolbar( chatInfo: ChatInfo, - chatItems: List, + reversedChatItems: State>, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, @@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { - recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { + recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } @@ -138,10 +137,10 @@ private fun recheckItems(chatInfo: ChatInfo, for (ci in chatItems) { if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf - rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote - rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd - rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null - rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy + rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport + rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport + rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index d912f8e030..e2b44478af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -74,7 +74,7 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || @@ -125,6 +125,9 @@ fun SendMsgView( } when { progressByTimeout -> ProgressIndicator() + cs.contextItem is ComposeContextItem.ReportedItem -> { + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) + } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 25661f00a0..abfb3895d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea withChats { upsertGroupMember(rhId, groupInfo, member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, member) + } } else { break } @@ -83,7 +87,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea fun getContactsToAdd(chatModel: ChatModel, search: String): List { val s = search.trim().lowercase() - val memberContactIds = chatModel.groupMembers + val memberContactIds = chatModel.groupMembers.value .filter { it.memberCurrent } .mapNotNull { it.memberContactId } return chatModel.chats.value @@ -209,8 +213,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { +fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { BackHandler(onBack = close) // TODO derivedStateOf? val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -51,6 +55,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val scope = rememberCoroutineScope() GroupChatInfoLayout( chat, groupInfo, @@ -61,14 +66,18 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, - members = chatModel.groupMembers + members = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, + onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, + scrollToItemId, addMembers = { - withBGApi { + scope.launch(Dispatchers.Default) { setGroupMembers(rhId, groupInfo, chatModel) + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } @@ -192,6 +201,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe withChats { upsertGroupMember(rhId, groupInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } }, @@ -275,7 +287,9 @@ fun ModalData.GroupChatInfoLayout( setSendReceipts: (SendReceipts) -> Unit, members: List, developerTools: Boolean, + onLocalAliasChanged: (String) -> Unit, groupLink: String?, + scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, @@ -303,20 +317,23 @@ fun ModalData.GroupChatInfoLayout( Box { val oneHandUI = remember { appPrefs.oneHandUI.state } LazyColumnWithScrollBar( + state = listState, contentPadding = if (oneHandUI.value) { PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) } else { PaddingValues(top = topPaddingToContent(false)) - }, - state = listState + } ) { item { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - GroupChatInfoHeader(chat.chatInfo) + GroupChatInfoHeader(chat.chatInfo, groupInfo) } + + LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) + SectionSpacer() Box( @@ -352,6 +369,13 @@ fun ModalData.GroupChatInfoLayout( } val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) + if (groupInfo.canModerate) { + GroupReportsButton { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { @@ -440,26 +464,33 @@ fun ModalData.GroupChatInfoLayout( } @Composable -private fun GroupChatInfoHeader(cInfo: ChatInfo) { +private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(groupInfo.groupProfile.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( - cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + groupInfo.groupProfile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != groupInfo.groupProfile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 8, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } @@ -474,6 +505,15 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) ) } +@Composable +private fun GroupReportsButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.group_reports_member_reports), + click = onClick + ) +} + @Composable private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { val values = remember { @@ -707,6 +747,15 @@ private fun SearchRowView( } } +private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + withChats { + updateGroup(chatRh, it) + } + } +} + @Preview @Composable fun PreviewGroupChatInfoLayout() { @@ -723,7 +772,9 @@ fun PreviewGroupChatInfoLayout() { setSendReceipts = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, + onLocalAliasChanged = {}, groupLink = null, + scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e4e5494364..ef1c69a5bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -8,6 +8,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -27,6 +28,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -64,6 +66,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -82,7 +87,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) if (chatModel.getContactChat(it) != null) { closeAll() } @@ -97,8 +102,8 @@ fun GroupMemberInfoView( val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) withChats { addChat(memberChat) - openLoadedChat(memberChat) } + openLoadedChat(memberChat) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } @@ -141,6 +146,9 @@ fun GroupMemberInfoView( withChats { upsertGroupMember(rhId, groupInfo, mem) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, mem) + } }.onFailure { newRole.value = prevValue } @@ -156,6 +164,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -170,6 +181,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -187,6 +201,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -202,16 +219,16 @@ fun GroupMemberInfoView( verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r - withChats { - upsertGroupMember( - rhId, - groupInfo, - mem.copy( - activeConn = mem.activeConn?.copy( - connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null - ) - ) + val copy = mem.copy( + activeConn = mem.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) + ) + withChats { + upsertGroupMember(rhId, groupInfo, copy) + } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, copy) } r } @@ -245,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withChats { upsertGroupMember(rhId, groupInfo, removedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, removedMember) + } } close?.invoke() } @@ -537,13 +557,19 @@ fun GroupMemberInfoHeader(member: GroupMember) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(member.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (member.fullName != "" && member.fullName != member.displayName) { Text( @@ -551,7 +577,8 @@ fun GroupMemberInfoHeader(member: GroupMember) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } @@ -745,6 +772,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withChats { upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + } } } } @@ -778,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke withChats { upsertGroupMember(rhId, gInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 0a807e1d63..3d9f42f929 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> updateGroup(rhId, g) currentPreferences = preferences } + withChats { + updateGroup(rhId, g) + } } afterSave() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index e81722f3f0..3163c109e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt new file mode 100644 index 0000000000..a1ec3ec0a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.* + +val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } + +data class GroupReports( + val reportsCount: Int, + val reportsView: Boolean, +) { + val showBar: Boolean = reportsCount > 0 && !reportsView + + fun toContentTag(): MsgContentTag? { + if (!reportsView) return null + return MsgContentTag.Report + } + + val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report +} + +@Composable +private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { + ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) +} + +@Composable +fun GroupReportsAppBar( + groupReports: State, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.group_reports_member_reports), + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + ItemsReload(groupReports) +} + +@Composable +private fun ItemsReload(groupReports: State) { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .drop(1) + .filterNotNull() + .map { chatModel.getChat(it) } + .filterNotNull() + .filter { it.chatInfo is ChatInfo.Group } + .collect { chat -> + reloadItems(chat, groupReports) + } + } +} + +suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { + openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) + ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close -> + ModalView({}, showAppBar = false) { + val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { + GroupReportsView(staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} + +private suspend fun reloadItems(chat: Chat, groupReports: State) { + val contentFilter = groupReports.value.toContentTag() + apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 6ebd4b13c3..703d74f225 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -27,6 +27,7 @@ import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatJsonLength diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 9bb3cef1d7..7711ee73af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.platform.onRightClick +import chat.simplex.common.views.chat.group.LocalContentTag @Composable fun CIChatFeatureView( @@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List = arrayListOf() val icons: MutableSet = mutableSetOf() - var i = getChatItemIndexOrNull(chatItem) + val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { - val reversedChatItems = m.chatItems.asReversed() while (i < reversedChatItems.size) { val f = featureInfo(reversedChatItems[i], chatInfo) ?: break if (!icons.contains(f.icon)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 59643afdf4..542623028a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,18 +1,21 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.background +import SectionItemView import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -92,25 +95,13 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -184,14 +175,26 @@ fun CIFileView( } } + val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } + val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + if (loadedFilePath != null && file?.fileSource != null) { + val encrypted = file.fileSource.cryptoArgs != null + SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) + } Row( Modifier .combinedClickable( - onClick = { fileAction() }, + onClick = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + }, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), - //Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { @@ -223,6 +226,47 @@ fun CIFileView( fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { + val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) + val btn = err.moreInfoButton + if (btn != null) { + showContentBlockedAlert(title, err.errorInfo) + } else { + AlertManager.shared.showAlertMsg(title, err.errorInfo) + } +} + +val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption" + +fun showContentBlockedAlert(title: String, message: String) { + AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) +} + +@Composable +expect fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) + @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index b7fe9ea4cf..401d098bea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -238,25 +238,13 @@ fun CIImageView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.fileStatus is CIFileStatus.RcvTransfer -> {} // ? file.fileStatus is CIFileStatus.RcvComplete -> {} // ? file.fileStatus is CIFileStatus.RcvCancelled -> {} // TODO diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 9f7b5dc9c6..8289149ad9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -499,10 +499,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) is CIFileStatus.SndWarning -> @@ -510,10 +507,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) @@ -532,10 +526,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) is CIFileStatus.RcvWarning -> @@ -543,10 +534,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 4aedcc013a..136300e4ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -398,10 +398,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) file != null && file.fileStatus is CIFileStatus.SndWarning -> @@ -411,10 +408,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) file?.fileStatus is CIFileStatus.RcvInvitation -> @@ -430,10 +424,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) file != null && file.fileStatus is CIFileStatus.RcvWarning -> @@ -443,10 +434,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) file != null && file.loaded && progress != null && duration != null -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 22842eb350..3b2bf63f28 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.HoverInteraction @@ -20,6 +21,7 @@ import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -28,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -255,7 +258,7 @@ fun ChatItemView( @Composable fun MsgReactionsMenu() { - val rs = MsgReaction.values.mapNotNull { r -> + val rs = MsgReaction.old.mapNotNull { r -> if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { r } else { @@ -295,7 +298,17 @@ fun ChatItemView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) when { // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.content.msgContent != null && cItem.id >= 0 -> { + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem, showMenu, deleteMessage) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -383,9 +396,13 @@ fun ChatItemView( if (!(live && cItem.meta.isLive) && !preview) { DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + // ReportItemAction(cItem, composeState, showMenu) + // } } if (cItem.canBeDeletedForSelf) { Divider() @@ -465,7 +482,7 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -500,8 +517,8 @@ fun ChatItemView( DeleteItemMenu() } - fun mergedGroupEventText(chatItem: ChatItem): String? { - val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) val members = when { ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) @@ -520,9 +537,9 @@ fun ChatItemView( } } - fun eventItemViewText(): AnnotatedString { + fun eventItemViewText(reversedChatItems: List): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem) + val t = mergedGroupEventText(cItem, reversedChatItems) return if (!revealed.value && t != null) { chatEventText(t, cItem.timestampText) } else if (memberDisplayName != null) { @@ -536,12 +553,13 @@ fun ChatItemView( } @Composable fun EventItemView() { - CIEventView(eventItemViewText()) + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) } @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) @@ -728,21 +746,23 @@ fun DeleteItemAction( questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + buttonText: String = stringResource(MR.strings.delete_verb), ) { + val contentTag = LocalContentTag.current ItemAction( - stringResource(MR.strings.delete_verb), + buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed() + val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems) val ciCategory = cItem.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { val itemIds: ArrayList = arrayListOf() - val reversedChatItems = chatModel.chatItems.asReversed() for (i in range) { itemIds.add(reversedChatItems[i].id) } @@ -847,6 +867,73 @@ private fun ShrinkItemAction(revealed: State, showMenu: MutableState, + showMenu: MutableState, +) { + ItemAction( + stringResource(MR.strings.report_verb), + painterResource(MR.images.ic_flag), + onClick = { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.report_reason_alert_title), + buttons = { + ReportReason.supportedReasons.forEach { reason -> + SectionItemView({ + if (composeState.value.editing) { + composeState.value = ComposeState( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } else { + composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } + AlertManager.shared.hideAlert() + }) { + Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { + ItemAction( + stringResource(MR.strings.archive_report), + painterResource(MR.images.ic_inventory_2), + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title), + text = generalGetString(MR.strings.report_archive_alert_desc), + onConfirm = { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + }, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) + showMenu.value = false + }, + color = Color.Red + ) +} + @Composable fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { @@ -867,6 +954,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on } } +@Composable +fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) { + val finalColor = if (textColor == Color.Unspecified) { + MenuTextColor + } else textColor + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (iconColor == Color.Unspecified) { + Image(icon, text, Modifier.size(22.dp)) + } else { + Icon(icon, text, Modifier.size(22.dp), tint = iconColor) + } + } + } +} + @Composable fun ItemAction( text: String, @@ -1107,7 +1220,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote) { + if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e955428031..784563dbb2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -88,7 +88,7 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote Row( @@ -104,7 +104,7 @@ fun FramedItemView( icon, caption, Modifier.size(18.dp), - tint = if (isInDarkTheme()) FileDark else FileLight + tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } Text( @@ -128,17 +128,6 @@ fun FramedItemView( Modifier .background(if (sent) sentColor else receivedColor) .fillMaxWidth() - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { - if (qi.itemId != null) { - scrollToItem(qi.itemId) - } else { - scrollToQuotedItemFromItem(ci.id) - } - } - ) - .onRightClick { showMenu.value = true } ) { when (qi.content) { is MsgContent.MCImage -> { @@ -216,28 +205,66 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.meta.itemDeleted != null) { - when (ci.meta.itemDeleted) { - is CIDeleted.Moderated -> { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + @Composable + fun Header() { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + } else { + stringResource(MR.strings.report_item_archived) + } + FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) } - is CIDeleted.Blocked -> { - FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.BlockedByAdmin -> { - FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.Deleted -> { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } else if (ci.meta.itemDeleted != null) { + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.BlockedByAdmin -> { + FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.Deleted -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(MR.strings.live), false) } - } else if (ci.meta.isLive) { - FramedItemHeader(stringResource(MR.strings.live), false) } if (ci.quotedItem != null) { - ciQuoteView(ci.quotedItem) - } else if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + Column( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (ci.quotedItem.itemId != null) { + scrollToItem(ci.quotedItem.itemId) + } else { + scrollToQuotedItemFromItem(ci.id) + } + } + ) + .onRightClick { showMenu.value = true } + ) { + Header() + ciQuoteView(ci.quotedItem) + } + } else { + Header() + if (ci.meta.itemForwarded != null) { + FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { @@ -288,6 +315,14 @@ fun FramedItemView( CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + } else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -315,13 +350,14 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, + prefix: AnnotatedString? = null ) { Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index d2e19a37d6..d63094cd1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -12,15 +12,17 @@ import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(ci, revealed) + MergedMarkedDeletedText(ci, chatInfo, revealed) } CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State) { - var i = getChatItemIndexOrNull(chatItem) +private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { - val reversedChatItems = ChatModel.chatItems.asReversed() var moderated = 0 var blocked = 0 var blockedByAdmin = 0 @@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem, chatInfo) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +79,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem, chatInfo) } Text( @@ -91,10 +93,17 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(meta: CIMeta): String = - when (meta.itemDeleted) { +fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) + } else { + generalGetString(MR.strings.report_item_archived) + } + } + else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> - String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName) is CIDeleted.Blocked -> generalGetString(MR.strings.blocked_item_description) is CIDeleted.BlockedByAdmin -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 434cde608a..257ede7d4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -71,7 +71,8 @@ fun MarkdownText ( inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, - showTimestamp: Boolean = true + showTimestamp: Boolean = true, + prefix: AnnotatedString? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -123,6 +124,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { @@ -136,6 +138,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) else if (toggleSecrets && ft.format is Format.Secret) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 226030fcd4..3ba15bc79c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -4,8 +4,6 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -13,7 +11,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.* +import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign @@ -21,11 +20,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.deleteGroupDialog -import chat.simplex.common.views.chat.group.leaveGroupDialog +import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* @@ -33,7 +32,6 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock -import kotlin.math.min @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { @@ -66,13 +64,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) + val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, - click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) @@ -84,14 +83,15 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { nextChatSelected, ) } - is ChatInfo.Group -> + is ChatInfo.Group -> { + val defaultClickAction = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) } }, - click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) @@ -102,11 +102,12 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { selectedChat, nextChatSelected, ) + } is ChatInfo.Local -> { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {}) } }, click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, @@ -204,28 +205,33 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId) +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) -private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) = - apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) +private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = + apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) -fun openLoadedChat(chat: Chat) { - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id - chatModel.chatState.clear() +suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { + withChats(contentTag) { + chatItemStatuses.clear() + chatItems.replaceAll(chat.chatItems) + chatModel.chatId.value = chat.chatInfo.id + chatModel.chatStateForContent(contentTag).clear() + } } -suspend fun apiFindMessages(ch: Chat, search: String) { - chatModel.chatItems.clearAndNotify() - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) +suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { + withChats(contentTag) { + chatItems.clearAndNotify() + } + apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search) } -suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { + // groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) - val currentMembers = chatModel.groupMembers + val currentMembers = chatModel.groupMembers.value val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } val currentMemberStats = currentMember?.activeConn?.connectionStats @@ -236,9 +242,8 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo newMember } } - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() - chatModel.groupMembers.addAll(newMembers) + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.groupMembers.value = newMembers chatModel.populateGroupMembersIndexes() } @@ -246,12 +251,13 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (contact.activeConn != null) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } DeleteContactAction(chat, chatModel, showMenu) @@ -285,12 +291,13 @@ fun GroupMenuItems( } else -> { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) @@ -305,7 +312,7 @@ fun GroupMenuItems( @Composable fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRead: Boolean) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -313,12 +320,12 @@ fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRea } @Composable -fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun MarkReadChatAction(chat: Chat, showMenu: MutableState) { ItemAction( stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), onClick = { - markChatRead(chat, chatModel) + markChatRead(chat) ntfManager.cancelNotificationsForChat(chat.id) showMenu.value = false } @@ -337,6 +344,28 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat ) } +@Composable +fun TagListAction( + chat: Chat, + showMenu: MutableState +) { + val userTags = remember { chatModel.userTags } + ItemAction( + stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list), + painterResource(MR.images.ic_label), + onClick = { + ModalManager.start.showModalCloseable { close -> + if (userTags.value.isEmpty()) { + TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close) + } else { + TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false) + } + } + showMenu.value = false + } + ) +} + @Composable fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState) { ItemAction( @@ -533,12 +562,15 @@ private fun InvalidDataView() { } } -fun markChatRead(c: Chat, chatModel: ChatModel) { +fun markChatRead(c: Chat) { var chat = c withApi { if (chat.chatStats.unreadCount > 0) { withChats { - markChatItemsRead(chat.remoteHostId, chat.chatInfo) + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + } + withReportsChatsIfOpen { + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } chatModel.controller.apiChatRead( chat.remoteHostId, @@ -557,6 +589,7 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + markChatTagRead(chat) } } } @@ -568,6 +601,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (chat.chatStats.unreadChat) return withApi { + val wasUnread = chat.unreadTag val success = chatModel.controller.apiChatUnread( chat.remoteHostId, chat.chatInfo.chatType, @@ -577,6 +611,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -826,12 +861,22 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch else -> false } if (res && newChatInfo != null) { + val chat = chatModel.getChat(chatInfo.id) + val wasUnread = chat?.unreadTag ?: false + val wasFavorite = chatInfo.chatSettings?.favorite ?: false + chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite) withChats { updateChatInfo(remoteHostId, newChatInfo) } if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chatInfo.id) } + val updatedChat = chatModel.getChat(chatInfo.id) + if (updatedChat != null) { + withChats { + updateChatTagReadNoContentTag(updatedChat, wasUnread) + } + } val current = currentState?.value if (current != null) { currentState.value = !current @@ -883,7 +928,8 @@ fun PreviewChatListNavLinkDirect() { disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, - progressByTimeout = false + progressByTimeout = false, + {} ) }, click = {}, @@ -928,7 +974,8 @@ fun PreviewChatListNavLinkGroup() { disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, - progressByTimeout = false + progressByTimeout = false, + {} ) }, click = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index ff776bc8ca..3205a084f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.platform.* -import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* @@ -31,22 +33,30 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call -import chat.simplex.common.views.chat.item.CIFileViewScope +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent -import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } + +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread: ActiveFilter() +} + private fun showNewChatSheet(oneHandUI: State) { ModalManager.start.closeModals() ModalManager.end.closeModals() @@ -142,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { val currentIndex = listState.firstVisibleItemIndex @@ -753,14 +777,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value val allChats = remember { chatModel.chats } // In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side // which is related to [derivedStateOf]. Using safe alternative instead // val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } } val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } - val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList()) + val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value) val topPaddingToContent = topPaddingToContent(false) val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent LazyColumnWithScrollBar( @@ -791,11 +814,15 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) { if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { + Divider() + TagsView(searchText) ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) + TagsView(searchText) + Divider() } } } @@ -815,8 +842,8 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) { - Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary) + Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + NoChatsView(searchText = searchText) } } if (oneHandUI.value) { @@ -839,6 +866,41 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } } + + LaunchedEffect(activeFilter.value) { + searchText.value = TextFieldValue("") + } +} + +@Composable +private fun NoChatsView(searchText: MutableState) { + val activeFilter = remember { chatModel.activeChatTagFilter }.value + + if (searchText.value.text.isBlank()) { + when (activeFilter) { + is ActiveFilter.PresetTag -> Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) // this should not happen + is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + is ActiveFilter.Unread -> { + Row( + Modifier.clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_filter_list), + null, + tint = MaterialTheme.colors.secondary + ) + Text(generalGetString(MR.strings.no_unread_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + null -> { + Text(generalGetString(MR.strings.no_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + Text(generalGetString(MR.strings.no_chats_found), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } @Composable @@ -860,48 +922,346 @@ private fun ChatListFeatureCards() { } } -fun filteredChats( - showUnreadAndFavorites: Boolean, - searchShowingSimplexLink: State, - searchChatFilteredBySimplexLink: State, - searchText: String, - chats: List -): List { - val linkChatId = searchChatFilteredBySimplexLink.value - return if (linkChatId != null) { - chats.filter { it.id == linkChatId } - } else { - val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() - if (s.isEmpty() && !showUnreadAndFavorites) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD } - else { - chats.filter { chat -> - when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( - if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) - } else { - cInfo.anyNameContains(s) - }) - is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited +private val TAG_MIN_HEIGHT = 35.dp + +@Composable +private fun TagsView(searchText: MutableState) { + val userTags = remember { chatModel.userTags } + val presetTags = remember { chatModel.presetTags } + val collapsiblePresetTags = presetTags.filter { presetCanBeCollapsed(it.key) && it.value > 0 } + val alwaysShownPresetTags = presetTags.filter { !presetCanBeCollapsed(it.key) && it.value > 0 } + val activeFilter = remember { chatModel.activeChatTagFilter } + val unreadTags = remember { chatModel.unreadTags } + val rhId = chatModel.remoteHostId() + + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + + TagsRow { + if (collapsiblePresetTags.size > 1) { + if (collapsiblePresetTags.size + alwaysShownPresetTags.size + userTags.value.size <= 3) { + PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag -> + ExpandedTagFilterView(tag) + } + } else { + CollapsedTagsFilterView(searchText) + alwaysShownPresetTags.forEach { tag -> + ExpandedTagFilterView(tag.key) + } + } + } + + userTags.value.forEach { tag -> + val current = when (val af = activeFilter.value) { + is ActiveFilter.UserTag -> af.tag == tag + else -> false + } + val interactionSource = remember { MutableInteractionSource() } + val showMenu = rememberSaveable { mutableStateOf(false) } + val saving = remember { mutableStateOf(false) } + Box { + Row( + rowSizeModifier + .clip(shape = CircleShape) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + } + }, + onLongClick = { showMenu.value = true }, + interactionSource = interactionSource, + indication = LocalIndication.current, + enabled = !saving.value + ) + .onRightClick { showMenu.value = true } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) } else { - cInfo.anyNameContains(s) + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(18.sp.toDp()), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) } - is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value) - is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) + } + } + Text( + text = invisibleText, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } + } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + TagsDropdownMenu(rhId, tag, showMenu, saving) + } + } + val plusClickModifier = Modifier + .clickable { + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close) + } + } + + if (userTags.value.isEmpty()) { + Row(rowSizeModifier.clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(2.dp)) + Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary, fontSize = 15.sp) + } + } else { + Box(rowSizeModifier, contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary + ) + } + } + } +} + +@Composable +expect fun TagsRow(content: @Composable() (() -> Unit)) + +@Composable +private fun ExpandedTagFilterView(tag: PresetTagKind) { + val activeFilter = remember { chatModel.activeChatTagFilter } + val active = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> af.tag == tag + else -> false + } + val (icon, text) = presetTagLabel(tag, active) + val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + + Row( + modifier = Modifier + .sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + .clip(shape = CircleShape) + .clickable { + if (activeFilter.value == ActiveFilter.PresetTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag) + } + } + .padding(horizontal = 5.dp, vertical = 4.dp) + , + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(icon), + stringResource(text), + Modifier.size(18.sp.toDp()), + tint = color + ) + Spacer(Modifier.width(4.dp)) + Box { + Text( + stringResource(text), + color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + fontWeight = if (active) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp + ) + Text( + stringResource(text), + color = Color.Transparent, + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + } + } +} + + +@Composable +private fun CollapsedTagsFilterView(searchText: MutableState) { + val activeFilter = remember { chatModel.activeChatTagFilter } + val presetTags = remember { chatModel.presetTags } + val showMenu = remember { mutableStateOf(false) } + + val selectedPresetTag = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null + else -> null + } + + Box(Modifier + .clip(shape = CircleShape) + .size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + .clickable { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + if (selectedPresetTag != null) { + val (icon, text) = presetTagLabel(selectedPresetTag, true) + Icon( + painterResource(icon), + stringResource(text), + Modifier.size(18.sp.toDp()), + tint = MaterialTheme.colors.primary + ) + } else { + Icon( + painterResource(MR.images.ic_menu), + stringResource(MR.strings.chat_list_all), + tint = MaterialTheme.colors.secondary + ) + } + + val onCloseMenuAction = remember { mutableStateOf<(() -> Unit)>({}) } + + DefaultDropdownMenu(showMenu = showMenu, onClosed = onCloseMenuAction) { + if (activeFilter.value != null || searchText.value.text.isNotBlank()) { + ItemAction( + stringResource(MR.strings.chat_list_all), + painterResource(MR.images.ic_menu), + onClick = { + onCloseMenuAction.value = { + searchText.value = TextFieldValue() + chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = {} + } + showMenu.value = false + } + ) + } + PresetTagKind.entries.forEach { tag -> + if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) { + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } } } } -private fun filtered(chat: Chat): Boolean = - (chat.chatInfo.chatSettings?.favorite ?: false) || - chat.chatStats.unreadChat || - (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) +@Composable +fun ItemPresetFilterAction( + presetTag: PresetTagKind, + active: Boolean, + showMenu: MutableState, + onCloseMenuAction: MutableState<(() -> Unit)> +) { + val (icon, text) = presetTagLabel(presetTag, active) + ItemAction( + stringResource(text), + painterResource(icon), + color = if (active) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + onCloseMenuAction.value = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = {} + } + showMenu.value = false + } + ) +} + +fun filteredChats( + searchShowingSimplexLink: State, + searchChatFilteredBySimplexLink: State, + searchText: String, + chats: List, + activeFilter: ActiveFilter? = null, +): List { + val linkChatId = searchChatFilteredBySimplexLink.value + return if (linkChatId != null) { + chats.filter { it.id == linkChatId } + } else { + val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() + if (s.isEmpty()) + chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) } + else { + chats.filter { chat -> + chat.id == chatModel.chatId.value || + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> !cInfo.contact.chatDeleted && !chat.chatInfo.contactCard && cInfo.anyNameContains(s) + is ChatInfo.Group -> cInfo.anyNameContains(s) + is ChatInfo.Local -> cInfo.anyNameContains(s) + is ChatInfo.ContactRequest -> cInfo.anyNameContains(s) + is ChatInfo.ContactConnection -> cInfo.contactConnection.localAlias.lowercase().contains(s) + is ChatInfo.InvalidJSON -> false + } + } + } + } +} + +private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = + when (activeFilter) { + is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) + is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false + is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + else -> true + } + +fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat.ChatStats): Boolean = + when (tag) { + PresetTagKind.GROUP_REPORTS -> chatStats.reportsCount > 0 + PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true + PresetTagKind.CONTACTS -> when (chatInfo) { + is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Customer + else -> false + } + PresetTagKind.GROUPS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null + else -> false + } + PresetTagKind.BUSINESS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business + else -> false + } + PresetTagKind.NOTES -> when (chatInfo) { + is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted + else -> false + } + } + +private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = + when (tag) { + PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports + PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites + PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts + PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups + PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses + PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes + } + +private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) { + PresetTagKind.GROUP_REPORTS -> false + else -> true +} fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 0e0c3e74f4..ba7334522a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.InlineTextContent @@ -21,11 +22,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* @@ -44,7 +47,8 @@ fun ChatPreviewView( disabled: Boolean, linkMode: SimplexLinkMode, inProgress: Boolean, - progressByTimeout: Boolean + progressByTimeout: Boolean, + defaultClickAction: () -> Unit ) { val cInfo = chat.chatInfo @@ -174,13 +178,23 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci.meta) to null + else -> markedDeletedText(ci, chat.chatInfo) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null ci.meta.itemDeleted == null -> ci.formattedText else -> null } + val prefix = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> + buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + else -> null + } + MarkdownText( text, formattedText, @@ -202,6 +216,7 @@ fun ChatPreviewView( ), inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth(), + prefix = prefix ) } } else { @@ -236,7 +251,38 @@ fun ChatPreviewView( val uriHandler = LocalUriHandler.current when (mc) { is MsgContent.MCLink -> SmallContentPreview { - IconButton({ uriHandler.openUriCatching(mc.preview.uri) }, Modifier.desktopPointerHoverIconHand()) { + val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO + IconButton({ + when (appPrefs.privacyChatListOpenLinks.get()) { + PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri) + PrivacyChatListOpenLinksMode.NO -> defaultClickAction() + PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question), + text = mc.preview.uri, + buttons = { + Column { + if (chatModel.chatId.value != chat.id) { + SectionItemView({ + AlertManager.shared.hideAlert() + defaultClickAction() + }) { + Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(mc.preview.uri) + } + ) { + Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) + } + }, + if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier, + ) { Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop) } Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) { @@ -310,6 +356,8 @@ fun ChatPreviewView( } else if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() + } else if (chat.chatStats.reportsCount > 0) { + GroupReportsIcon() } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -457,6 +505,18 @@ fun IncognitoIcon(incognito: Boolean) { } } +@Composable +fun GroupReportsIcon() { + Icon( + painterResource(MR.images.ic_flag), + contentDescription = null, + tint = MaterialTheme.colors.error, + modifier = Modifier + .size(21.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) +} + @Composable private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { return if (groupInfo.membership.memberIncognito) @@ -501,6 +561,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index e048c39fe7..aa9847c98a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -191,7 +191,7 @@ private fun ShareList( val chats by remember(search) { derivedStateOf { val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local } - filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted) + filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } val topPaddingToContent = topPaddingToContent(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt new file mode 100644 index 0000000000..1b563e6d02 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -0,0 +1,508 @@ +package chat.simplex.common.views.chatlist + +import SectionCustomFooter +import SectionDivider +import SectionItemView +import TextIconSpaced +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.apiDeleteChatTag +import chat.simplex.common.model.ChatController.apiSetChatTags +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.ReactionIcon +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() + val saving = remember { mutableStateOf(false) } + val chatTagIds = derivedStateOf { chat?.chatInfo?.chatTags ?: emptyList() } + + fun reorderTags(tagIds: List) { + saving.value = true + withBGApi { + try { + chatModel.controller.apiReorderChatTags(rhId, tagIds) + } catch (e: Exception) { + Log.d(TAG, "ChatListTag reorderTags error: ${e.message}") + } finally { + saving.value = false + } + } + } + + val dragDropState = + rememberDragDropState(listState) { fromIndex, toIndex -> + userTags.value = userTags.value.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + reorderTags(userTags.value.map { it.chatTagId }) + } + val topPaddingToContent = topPaddingToContent(false) + + LazyColumnWithScrollBar( + modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier, + state = listState, + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, + ) { + @Composable fun CreateList() { + SectionItemView({ + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close, chat = chat) + } + }) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.create_list), tint = MaterialTheme.colors.primary) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(MR.strings.create_list), color = MaterialTheme.colors.primary) + } + } + + if (oneHandUI.value && !reorderMode) { + item { + CreateList() + } + } + itemsIndexed(userTags.value, key = { _, item -> item.chatTagId }) { index, tag -> + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Card( + elevation = elevation, + backgroundColor = if (isDragging) colors.surface else Color.Unspecified + ) { + Column { + val selected = chatTagIds.value.contains(tag.chatTagId) + + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) + .clickable( + enabled = !saving.value && !reorderMode, + onClick = { + if (chat == null) { + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText, + ) + } + } else { + saving.value = true + setTag(rhId = rhId, tagId = if (selected) null else tag.chatTagId, chat = chat, close = { + saving.value = false + close() + }) + } + }, + ) + .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon(painterResource(MR.images.ic_label), null, Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.onBackground) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + tag.chatTagText, + color = MenuTextColor, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal + ) + if (selected) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } else if (reorderMode) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) + } + } + SectionDivider() + } + } + } + } + if (!oneHandUI.value && !reorderMode) { + item { + CreateList() + } + } + } +} + +@Composable +fun ModalData.TagListEditor( + rhId: Long?, + chat: Chat? = null, + tagId: Long? = null, + emoji: String? = null, + name: String = "", + close: () -> Unit +) { + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } } + val newName = remember { stateGetOrPut("chatTagName") { name } } + val saving = remember { mutableStateOf(null) } + val trimmedName = remember { derivedStateOf { newName.value.trim() } } + val isDuplicateEmojiOrName = remember { + derivedStateOf { + userTags.value.any { tag -> + tag.chatTagId != tagId && + ((newEmoji.value != null && tag.chatTagEmoji == newEmoji.value) || tag.chatTagText == trimmedName.value) + } + } + } + + fun createTag() { + saving.value = true + withBGApi { + try { + val updatedTags = chatModel.controller.apiCreateChatTag(rhId, ChatTagData(newEmoji.value, trimmedName.value)) + if (updatedTags != null) { + saving.value = false + userTags.value = updatedTags + close() + } else { + saving.value = null + return@withBGApi + } + + if (chat != null) { + val createdTag = updatedTags.firstOrNull() { it.chatTagText == trimmedName.value && it.chatTagEmoji == newEmoji.value } + + if (createdTag != null) { + setTag(rhId, createdTag.chatTagId, chat, close = { + saving.value = false + close() + }) + } + } + } catch (e: Exception) { + Log.d(TAG, "createChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + fun updateTag() { + saving.value = true + withBGApi { + try { + if (chatModel.controller.apiUpdateChatTag(rhId, tagId!!, ChatTagData(newEmoji.value, trimmedName.value))) { + userTags.value = userTags.value.map { tag -> + if (tag.chatTagId == tagId) { + tag.copy(chatTagEmoji = newEmoji.value, chatTagText = trimmedName.value) + } else { + tag + } + } + } else { + saving.value = null + return@withBGApi + } + saving.value = false + close() + } catch (e: Exception) { + Log.d(TAG, "ChatListTagEditor updateChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + val showError = derivedStateOf { isDuplicateEmojiOrName.value && saving.value != false } + + ColumnWithScrollBar(Modifier.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) WindowInsets.ime.asPaddingValues().calculateBottomPadding().coerceIn(0.dp, WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) else 0.dp))) { + if (oneHandUI.value) { + Spacer(Modifier.weight(1f)) + } + ChatTagInput(newName, showError, newEmoji) + val disabled = saving.value == true || + (trimmedName.value == name && newEmoji.value == emoji) || + trimmedName.value.isEmpty() || + isDuplicateEmojiOrName.value + + SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { + Text( + generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list), + color = if (disabled) colors.secondary else colors.primary + ) + } + val showErrorMessage = isDuplicateEmojiOrName.value && saving.value != false + SectionCustomFooter { + Row( + Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = stringResource(MR.strings.error), + tint = if (showErrorMessage) Color.Red else Color.Transparent, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + generalGetString(MR.strings.duplicated_list_error), + color = if (showErrorMessage) colors.secondary else Color.Transparent, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +fun TagsDropdownMenu(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = { + EditTagAction(rhId, tag, showMenu) + DeleteTagAction(rhId, tag, showMenu, saving) + ChangeOrderTagAction(rhId, showMenu) + }) +} + +@Composable +private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + ItemAction( + stringResource(MR.strings.delete_chat_list_menu_action), + painterResource(MR.images.ic_delete), + onClick = { + deleteTagDialog(rhId, tag, saving) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.edit_chat_list_menu_action), + painterResource(MR.images.ic_edit), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText + ) + } + }, + color = MenuTextColor + ) +} + +@Composable +private fun ChangeOrderTagAction(rhId: Long?, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.change_order_chat_list_menu_action), + painterResource(MR.images.ic_drag_handle), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListView(rhId = rhId, close = close, reorderMode = true) + } + }, + color = MenuTextColor + ) +} + +@Composable +expect fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) + +@Composable +fun TagListNameTextField(name: MutableState, showError: State) { + var focused by rememberSaveable { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .indicatorLine(true, showError.value, interactionSource, colors) + .heightIn(min = TextFieldDefaults.MinHeight) + .onFocusChanged { focused = it.isFocused } + .focusRequester(focusRequester), + textStyle = TextStyle(fontSize = 18.sp, color = MaterialTheme.colors.onBackground), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = name.value, + innerTextField = innerTextField, + placeholder = { + Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) + }, + contentPadding = PaddingValues(), + label = null, + visualTransformation = VisualTransformation.None, + leadingIcon = null, + singleLine = true, + enabled = true, + isError = false, + interactionSource = remember { MutableInteractionSource() }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) + ) + } + ) +} + +private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { + withBGApi { + val tagIds: List = if (tagId == null) { + emptyList() + } else { + listOf(tagId) + } + + try { + val result = apiSetChatTags(rh = rhId, type = chat.chatInfo.chatType, id = chat.chatInfo.apiId, tagIds = tagIds) + + if (result != null) { + val oldTags = chat.chatInfo.chatTags + chatModel.userTags.value = result.first + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = result.second) + withChats { + updateContact(rhId, contact) + } + } + + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = result.second) + withChats { + updateGroup(rhId, group) + } + } + + else -> {} + } + chatModel.moveChatTagUnread(chat, oldTags, result.second) + close() + } + } catch (e: Exception) { + Log.d(TAG, "setChatTag error: ${e.message}") + } + } +} + +private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) { + withBGApi { + saving.value = true + + try { + val tagId = tag.chatTagId + if (apiDeleteChatTag(rhId, tagId)) { + chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId } + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } + chatModel.chats.value.forEach { c -> + when (val cInfo = c.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId }) + withChats { + updateContact(rhId, contact) + } + } + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId }) + withChats { + updateGroup(rhId, group) + } + } + else -> {} + } + } + } + + } catch (e: Exception) { + Log.d(TAG, "deleteTag error: ${e.message}") + } finally { + saving.value = false + } + } +} + +private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_chat_list_question), + text = String.format(generalGetString(MR.strings.delete_chat_list_warning), tag.chatTagText), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + deleteTag(rhId, tag, saving) + }) { + Text( + generalGetString(MR.strings.confirm_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.error + ) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text( + stringResource(MR.strings.cancel_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.primary + ) + } + } + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 0af8e7ca38..da70aef621 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) { if (chatInfo is ChatInfo.Direct) { ModalManager.start.closeModals() if (chatInfo.contact.sndReady) { - openLoadedChat(chat) + withApi { + openLoadedChat(chat) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index bf59524a06..d951f1f812 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -60,8 +61,7 @@ fun DatabaseView() { if (to != null) { importArchiveAlert { stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - importArchive(to, appFilesCountAndSize, progressIndicator) - true + importArchive(to, appFilesCountAndSize, progressIndicator, false) } } } @@ -529,9 +529,14 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() withLongRunningApi { withChats { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } + withReportsChatsIfOpen { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } @@ -645,6 +650,7 @@ suspend fun importArchive( importedArchiveURI: URI, appFilesCountAndSize: MutableState>, progressIndicator: MutableState, + migration: Boolean ): Boolean { val m = chatModel progressIndicator.value = true @@ -666,12 +672,13 @@ suspend fun importArchive( if (chatModel.localUserCreated.value == false) { chatModel.chatRunning.value = false } + return true } else { operationEnded(m, progressIndicator) { showArchiveImportedWithErrorsAlert(archiveErrors) } + return migration } - return true } catch (e: Error) { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_importing_database), e.toString()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index 1f00af2809..c6a566c6f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -16,6 +15,7 @@ fun DefaultDropdownMenu( showMenu: MutableState, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), + onClosed: State<() -> Unit> = remember { mutableStateOf({}) }, dropdownMenuItems: (@Composable () -> Unit)? ) { MaterialTheme( @@ -31,6 +31,11 @@ fun DefaultDropdownMenu( offset = offset, ) { dropdownMenuItems?.invoke() + DisposableEffect(Unit) { + onDispose { + onClosed.value() + } + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 4bf20d2128..1c5f86c8b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -35,7 +35,8 @@ fun DefaultAppBar( // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier val modifier = if (!showSearch) { Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) - } else Modifier.imePadding() + } else if (!onTop) Modifier.imePadding() + else Modifier val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) val prefAlpha = remember { appPrefs.inAppBarsAlpha.state } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt new file mode 100644 index 0000000000..cded400892 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt @@ -0,0 +1,177 @@ +package chat.simplex.common.views.helpers + +/* + * This was adapted from google example of drag and drop for Jetpack Compose + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + */ + +import androidx.compose.animation.core.* +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { + val scope = rememberCoroutineScope() + val state = + remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState +internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + val touchY = offset.y.toInt() + val item = state.layoutInfo.visibleItemsInfo.minByOrNull { + val itemCenter = (it.offset - state.layoutInfo.viewportStartOffset) + it.size / 2 + kotlin.math.abs(touchY - itemCenter) // Find the item closest to the touch position, needs to take viewportStartOffset into account + } + + if (item != null) { + draggingItemIndex = item.index + draggingItemInitialOffset = item.offset + } + } + + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset + ) + } + onMove.invoke(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f).graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 28f6320ee7..1f2b5485f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -51,7 +51,7 @@ fun authenticateWithPasscode( close() completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) { close() completed(it) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 819efcdd9a..3e24629ab1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) { val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar) } +enum class ModalViewId { + GROUP_REPORTS +} + class ModalManager(private val placement: ModalPlacement? = null) { - private val modalViews = arrayListOf Unit) -> Unit)>>() + data class ModalViewHolder( + val id: ModalViewId?, + val animated: Boolean, + val data: ModalData, + val modal: @Composable ModalData.(close: () -> Unit) -> Unit + ) + + private val modalViews = arrayListOf() private val _modalCount = mutableStateOf(0) val modalCount: State = _modalCount private val toRemove = mutableSetOf() @@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) { private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) - fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { - showCustomModal { close -> + fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id } + + fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { - showCustomModal { close -> + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) }) } } - fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showCustomModal") val data = ModalData(keyboardCoversBar = keyboardCoversBar) // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. @@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) // to prevent unneeded animation on different situations val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START) - modalViews.add(Triple(anim, data, modal)) + modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size if (placement == ModalPlacement.CENTER) { @@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { - if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) + if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex) else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } } _modalCount.value = modalViews.size - toRemove.size @@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { @Composable fun showInView() { // Without animation - if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) { + if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) { modalViews.lastOrNull()?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } return @@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { } ) { modalViews.getOrNull(it - 1)?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } // This is needed because if we delete from modalViews immediately on request, animation will be bad diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 232b1723a0..7af36a0445 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri import chat.simplex.res.MR @@ -237,13 +238,26 @@ fun saveAnimImage(uri: URI): CryptoFile? { expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? -fun saveFileFromUri(uri: URI, withAlertOnException: Boolean = true): CryptoFile? { +fun saveFileFromUri( + uri: URI, + withAlertOnException: Boolean = true, + hiddenFileNamePrefix: String? = null +): CryptoFile? { return try { val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) + val destFileName = if (hiddenFileNamePrefix == null) { + uniqueCombine(fileToSave, File(getAppFilePath(""))) + } else { + val ext = when { + // remove everything but extension + fileToSave.contains(".") -> fileToSave.substringAfterLast(".") + else -> null + } + generateNewFileName(hiddenFileNamePrefix, ext, File(getAppFilePath(""))) + } val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -303,8 +317,33 @@ fun removeWallpaperFile(fileName: String? = null) { WallpaperType.cachedImages.remove(fileName) } -fun createTmpFileAndDelete(onCreated: (File) -> T): T { - val tmpFile = File(tmpDir, UUID.randomUUID().toString()) +fun removeWallpaperFilesFromTheme(theme: ThemeModeOverrides?) { + if (theme != null) { + removeWallpaperFile(theme.light?.wallpaper?.imageFile) + removeWallpaperFile(theme.dark?.wallpaper?.imageFile) + } +} + +fun removeWallpaperFilesFromChat(chat: Chat) { + if (chat.chatInfo is ChatInfo.Direct) { + removeWallpaperFilesFromTheme(chat.chatInfo.contact.uiThemes) + } else if (chat.chatInfo is ChatInfo.Group) { + removeWallpaperFilesFromTheme(chat.chatInfo.groupInfo.uiThemes) + } +} + +fun removeWallpaperFilesFromAllChats(user: User) { + // Currently, only removing everything from currently active user is supported. Inactive users are TODO + if (user.userId == chatModel.currentUser.value?.userId) { + chatModel.chats.value.forEach { + removeWallpaperFilesFromChat(it) + } + } +} + +fun createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T { + val tmpFile = File(dir, UUID.randomUUID().toString()) + tmpFile.parentFile.mkdirs() tmpFile.deleteOnExit() ChatModel.filesToDelete.add(tmpFile) try { @@ -314,11 +353,12 @@ fun createTmpFileAndDelete(onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String, dir: File): String { +fun generateNewFileName(prefix: String, ext: String?, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext", dir) + val extension = if (ext != null) ".$ext" else "" + return uniqueCombine("${prefix}_$timestamp$extension", dir) } fun uniqueCombine(fileName: String, dir: File): String { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index d3f3facbd9..8588e0e981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -174,7 +174,7 @@ private fun SectionByState( is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) is MigrationFromState.LinkCreation -> LinkCreationView() - is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl, chatReceiver.value) is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion) } } @@ -335,7 +335,7 @@ private fun LinkCreationView() { } @Composable -private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) { SectionView { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_close), @@ -356,7 +356,7 @@ private fun MutableState.LinkShownView(fileId: Long, link: S confirmText = generalGetString(MR.strings.continue_to_next_step), destructive = true, onConfirm = { - finishMigration(fileId, ctrl) + finishMigration(fileId, ctrl, chatReceiver) } ) } @@ -450,6 +450,7 @@ private fun MutableState.stopChat() { try { controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation + platform.androidChatStopped() } catch (e: Exception) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.migrate_from_device_error_saving_settings), @@ -617,9 +618,11 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { } } -private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) { withBGApi { cancelUploadedArchive(fileId, ctrl) + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() state = MigrationFromState.Finished(false) } } @@ -655,6 +658,7 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) { } else if (user != null) { startChat(user) } + platform.androidChatStartedAfterBeingOff() } catch (e: Exception) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.error_starting_chat), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 788c07a9d2..1a28bbf589 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -239,7 +239,7 @@ private fun ArchiveImportView(progressIndicator: MutableState, close: ( val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) { withLongRunningApi { - val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator) + val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator, true) if (success) { startChat( chatModel, @@ -691,6 +691,7 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) if (user != null) { startChat(user) } + platform.androidChatStartedAfterBeingOff() hideView(close) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration)) } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 6cecbe4979..2380c64a4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -44,8 +44,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c if (groupInfo != null) { withChats { updateGroup(rhId = rhId, groupInfo) - chatModel.chatItems.clearAndNotify() - chatModel.chatItemStatuses.clear() + chatItems.clearAndNotify() + chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index a5cb944f0a..a66ef9ff7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo @@ -766,7 +768,9 @@ private val versionDescriptions: List = listOf( private val lastVersion = versionDescriptions.last().version fun setLastVersionDefault(m: ChatModel) { - m.controller.appPrefs.whatsNewVersion.set(lastVersion) + if (appPrefs.whatsNewVersion.get() != lastVersion) { + appPrefs.whatsNewVersion.set(lastVersion) + } } fun shouldShowWhatsNew(m: ChatModel): Boolean { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 66b518e9aa..5af5d5fb90 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -78,7 +78,7 @@ fun NotificationsSettingsLayout( ) } if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization)) + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } } SectionBottomSpacer() @@ -95,7 +95,7 @@ fun NotificationsModeView( AppBarTitle(stringResource(MR.strings.settings_notifications_mode_title).lowercase().capitalize(Locale.current)) SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected) if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization)) + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 9ec2d29843..c411eb0d78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -63,6 +63,9 @@ fun PrivacySettingsView( SectionView(stringResource(MR.strings.settings_section_title_chats)) { SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) + ChatListLinksOptions(appPrefs.privacyChatListOpenLinks.state, onSelected = { + appPrefs.privacyChatListOpenLinks.set(it) + }) SettingsPreferenceItem( painterResource(MR.images.ic_chat_bubble), stringResource(MR.strings.privacy_show_last_messages), @@ -199,6 +202,26 @@ fun PrivacySettingsView( } } +@Composable +private fun ChatListLinksOptions(state: State, onSelected: (PrivacyChatListOpenLinksMode) -> Unit) { + val values = remember { + PrivacyChatListOpenLinksMode.entries.map { + when (it) { + PrivacyChatListOpenLinksMode.YES -> it to generalGetString(MR.strings.privacy_chat_list_open_links_yes) + PrivacyChatListOpenLinksMode.NO -> it to generalGetString(MR.strings.privacy_chat_list_open_links_no) + PrivacyChatListOpenLinksMode.ASK -> it to generalGetString(MR.strings.privacy_chat_list_open_links_ask) + } + } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.privacy_chat_list_open_links), + values, + state, + icon = painterResource(MR.images.ic_open_in_new), + onSelected = onSelected + ) +} + @Composable private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) @@ -422,7 +445,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -466,7 +489,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( reason = generalGetString(MR.strings.la_app_passcode), submit = { @@ -490,7 +513,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, @@ -525,7 +548,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -638,7 +661,7 @@ private fun EnableSelfDestruct( selfDestruct: SharedPreference, close: () -> Unit ) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), submit = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 51a0ffad8d..5bd45ccaab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -444,17 +444,19 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) { runAuth(title, desc, onFinishAuth) } } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ad732cd699..d7ddb6b950 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -347,6 +347,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de try { when { user.activeUser -> { + removeWallpaperFilesFromAllChats(user) val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } if (newActive != null) { m.controller.changeActiveUser_(user.remoteHostId, newActive.userId, null) @@ -366,6 +367,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de m.controller.apiDeleteUser(user, delSMPQueues, viewPwd) } } + removeWallpaperFilesFromTheme(user.uiThemes) m.removeUser(user) ntfManager.cancelNotificationsForUser(user.userId) } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index a209126a89..954c22abee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -37,6 +37,10 @@ %d messages marked deleted moderated by %s %1$d messages moderated by %2$s + Only you and moderators see it + Only sender and moderators see it + archived report + archived report by %s blocked blocked by admin %d messages blocked @@ -94,6 +98,13 @@ Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + + Spam + Inappropriate content + Community guidelines violation + Inappropriate profile + Another reason + Error saving SMP servers Error saving XFTP servers @@ -121,6 +132,8 @@ For chat profile %s: Errors in servers configuration. Error accepting conditions + Spam + Content violates conditions of use Connection timeout @@ -139,6 +152,7 @@ Error sending message Error forwarding messages Error creating message + Error creating report Error loading details Error adding member(s) Error joining group @@ -156,6 +170,8 @@ Please check that you used the correct link or ask your contact to send you another one. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. + Connection blocked + Connection is blocked by server operator:\n%1$s. Undelivered messages The connection reached the limit of undelivered messages, your contact may be offline. Error accepting contact request @@ -188,6 +204,9 @@ Error updating user privacy Slow function Execution of function takes too long time: %1$d seconds: %2$s + Error updating chat list + Error creating chat list + Error loading chat lists Instant notifications @@ -291,6 +310,9 @@ Most likely this contact has deleted the connection with you. No message This message was deleted or not received yet. + Report reason? + Archive report? + The report will be archived for you. Error: %1$s @@ -305,6 +327,7 @@ Wrong key or unknown file chunk address - most likely file is deleted. + File is blocked by server operator:\n%1$s. File not found - most likely file was deleted or cancelled. File server error: %1$s @@ -316,6 +339,9 @@ Edit Info Search + Archive + Archive report + Delete report Sent message Received message History @@ -333,6 +359,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -361,6 +388,7 @@ Revoke Forward Download + List Message forwarded No direct connection yet, message is forwarded by admin. @@ -390,6 +418,10 @@ You have no chats Loading chats… No filtered chats + No chats in list %s. + No unread chats + No chats + No chats found Tap to Connect Connect with %1$s? Search or paste SimpleX link @@ -409,6 +441,18 @@ %1$d file(s) were deleted. Download %1$s messages not forwarded + Favorites + Contacts + Groups + Businesses + Notes + Reports + All + Add list + 1 report + %d reports + Member reports + Archived member reports Share message… @@ -447,6 +491,11 @@ Please reduce the message size and send again. Please reduce the message size or remove media and send again. You can copy and reduce the message size to send it. + Report spam: only group moderators will see it. + Report member profile: only group moderators will see it. + Report violation: only group moderators will see it. + Report content: only group moderators will see it. + Report other: only group moderators will see it. Image @@ -482,6 +531,7 @@ Please, wait while the file is being loaded from the linked mobile File error Temporary file error + Open with %s Voice message @@ -512,6 +562,7 @@ Contact deleted! You can still view conversation with %1$s in the list of chats. Set contact name… + Set chat name… Connected Disconnected Error @@ -626,6 +677,18 @@ Favorite Unfavorite + + Create list + Add to list + Change list + Save list + List name... + List name and emoji should be different for all lists. + Delete + Delete list? + All chats will be removed from the list %s, and the list deleted + Edit + Change order You invited a contact @@ -914,6 +977,7 @@ Show slow API calls Shutdown? Notifications will stop working until you re-launch the app + Error saving settings Create address @@ -1244,6 +1308,12 @@ Soft Medium Strong + Open links from chat list + Yes + No + Ask + Open web link? + Open link YOU @@ -1527,6 +1597,7 @@ observer author member + moderator admin owner diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg new file mode 100644 index 0000000000..99d3e66fe8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg new file mode 100644 index 0000000000..a6f5a70618 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg new file mode 100644 index 0000000000..0f9889083d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg new file mode 100644 index 0000000000..6291f7ab8e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg new file mode 100644 index 0000000000..f30bc0db2c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg new file mode 100644 index 0000000000..02c84c9d05 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg new file mode 100644 index 0000000000..3b58600ae2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg new file mode 100644 index 0000000000..0ed867b156 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg new file mode 100644 index 0000000000..fb5c122eec --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 2702862e47..9d747206ab 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -55,8 +57,15 @@ fun showApp() { // Better to not close fullscreen since it can contain passcode } else { // The last possible cause that can be closed - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + } + } } chatModel.activeCall.value?.let { withBGApi { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index eeeb13e5cc..f7a87e3ced 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -17,6 +17,8 @@ actual val wallpapersDir: File = File(dataDir.absolutePath + File.separator + "s actual val coreTmpDir: File = File(dataDir.absolutePath + File.separator + "tmp") actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "simplex_v1" actual val preferencesDir = File(desktopPlatform.configPath).also { it.parentFile.mkdirs() } +actual val preferencesTmpDir = File(desktopPlatform.configPath, "tmp") + .also { it.deleteRecursively() } actual val chatDatabaseFileName: String = "simplex_v1_chat.db" actual val agentDatabaseFileName: String = "simplex_v1_agent.db" diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 951185dc98..4e7594f998 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -9,15 +9,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import com.jthemedetecor.OsThemeDetector import com.russhwolf.settings.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.desc.desc -import kotlinx.coroutines.* import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.* -import java.util.concurrent.Executors @Composable actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font = @@ -37,10 +38,8 @@ catch (e: Exception) { private val settingsFile = File(desktopPlatform.configPath + File.separator + "settings.properties") - .also { it.parentFile.mkdirs() } private val settingsThemesFile = File(desktopPlatform.configPath + File.separator + "themes.properties") - .also { it.parentFile.mkdirs() } private val settingsProps = Properties() @@ -61,11 +60,35 @@ private val settingsThemesProps = Properties() .also { props -> try { settingsThemesFile.reader().use { props.load(it) } } catch (e: Exception) { /**/ } } - -private val settingsWriterThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - -actual val settings: Settings = PropertiesSettings(settingsProps) { CoroutineScope(settingsWriterThread).launch { settingsFile.writer().use { settingsProps.store(it, "") } } } -actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { CoroutineScope(settingsWriterThread).launch { settingsThemesFile.writer().use { settingsThemesProps.store(it, "") } } } +private const val lock = "settingsSaver" +actual val settings: Settings = PropertiesSettings(settingsProps) { + synchronized(lock) { + try { + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + tmpFile.writer().use { settingsProps.store(it, "") } + settingsFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), settingsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString()) + throw e + } + } +} +actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { + synchronized(lock) { + try { + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + tmpFile.writer().use { settingsThemesProps.store(it, "") } + settingsThemesFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), settingsThemesFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString()) + throw e + } + } +} actual fun windowOrientation(): WindowOrientation = if (simplexWindowState.windowState.size.width > simplexWindowState.windowState.size.height) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 785c3b40fa..3f5703365d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -36,6 +36,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -93,7 +94,7 @@ actual fun LazyColumnWithScrollBar( val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier) else modifier Box(Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).nestedScroll(connection)) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -108,6 +109,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { @@ -135,7 +137,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( val scrollBarDraggingState = remember { mutableStateOf(false) } Box { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -147,11 +149,13 @@ private fun ScrollBar( scrollJob: MutableState, scrollBarDraggingState: MutableState, additionalBarHeight: State?, + additionalTopBar: State, chatBottomBar: State, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } + val topBarPadding = if (additionalTopBar.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp val padding = if (additionalBarHeight != null) { - PaddingValues(top = if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) + PaddingValues(top = topBarPadding + if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) } else if (reverseLayout) { PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier) } else { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt new file mode 100644 index 0000000000..eceb7de9be --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.CryptoFile +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 9789fa3d1a..a1f70213d0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -4,8 +4,7 @@ import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 3fa78bbbb5..e295144191 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -21,6 +21,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +@OptIn(ExperimentalLayoutApi::class) +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val showMenu = remember { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt new file mode 100644 index 0000000000..75a76014a9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -0,0 +1,72 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex +import chat.simplex.common.views.chat.item.isHeartEmoji +import chat.simplex.common.views.chat.item.isShortEmoji +import chat.simplex.common.views.helpers.toDp +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SingleEmojiInput(emoji) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun SingleEmojiInput( + emoji: MutableState +) { + val state = remember { mutableStateOf(TextFieldValue(emoji.value ?: "")) } + val colors = TextFieldDefaults.textFieldColors( + textColor = if (isHeartEmoji(emoji.value ?: "")) Color(0xffD63C31) else MaterialTheme.colors.onPrimary, + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) + TextField( + value = state.value, + onValueChange = { newValue -> + if (newValue.text == emoji.value) { + state.value = newValue + return@TextField + } + val newValueClamped = newValue.text.replace(emoji.value ?: "", "") + val isEmoji = isShortEmoji(newValueClamped) + emoji.value = if (isEmoji) newValueClamped else null + state.value = if (isEmoji) newValue else TextFieldValue() + }, + singleLine = true, + modifier = Modifier + .padding(4.dp) + .size(width = TextFieldDefaults.MinHeight.value.sp.toDp(), height = TextFieldDefaults.MinHeight), + textStyle = LocalTextStyle.current.copy(fontFamily = EmojiFont, textAlign = TextAlign.Center), + placeholder = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + }, + colors = colors, + ) +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cde173f24c..6c04bf65d7 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.1 -android.version_code=261 +android.version_name=6.3-beta.1 +android.version_code=270 -desktop.version_name=6.2.1 -desktop.version_code=83 +desktop.version_name=6.3-beta.1 +desktop.version_code=88 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index cedbd4fe34..6c3d8240e4 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -31,9 +31,9 @@ main = do welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot" + opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts welcomeMessage :: Text diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index c24f9c251f..290e6286b1 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -25,7 +25,7 @@ welcomeMessage = "Hello! I am a simple squaring bot.\nIf you send me a number, I welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot" + opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index c526d64886..9dc927af9e 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -27,9 +27,9 @@ import System.Directory (getAppUserDataDirectory) welcomeGetOpts :: IO BroadcastBotOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot" + opts@BroadcastBotOpts {coreOptions} <- getBroadcastBotOpts appDir "simplex_status_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 5bc4ffef25..e695b5069d 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -27,8 +27,8 @@ defaultProhibitedMessage :: [KnownContact] -> Text defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted." broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts -broadcastBotOpts appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +broadcastBotOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName publishers <- option parseKnownContacts @@ -61,10 +61,10 @@ broadcastBotOpts appDir defaultDbFileName = do } getBroadcastBotOpts :: FilePath -> FilePath -> IO BroadcastBotOpts -getBroadcastBotOpts appDir defaultDbFileName = +getBroadcastBotOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> broadcastBotOpts appDir defaultDbFileName) + (helper <*> versionOption <*> broadcastBotOpts appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start chat bot with DB_FILE file and use SERVER as SMP server") where versionStr = versionString versionNumber diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index af9c9dd252..0c6464dbfe 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -10,6 +10,8 @@ import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog} <- welcomeGetOpts + opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts st <- restoreDirectoryStore directoryLog - simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts + if runCLI + then directoryServiceCLI st opts + else simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index ce165a1344..19c9405358 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -116,6 +116,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCListPendingGroups_ :: DirectoryCmdTag 'DRAdmin DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin + DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -141,6 +142,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCListPendingGroups :: Int -> DirectoryCmd 'DRAdmin DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin + DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -181,6 +183,7 @@ directoryCmdP = "pending" -> au DCListPendingGroups_ "link" -> au DCShowGroupLink_ "owner" -> au DCSendToGroupOwner_ + "invite" -> au DCInviteOwnerToGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -216,6 +219,7 @@ directoryCmdP = (groupId, displayName) <- gc (,) msg <- A.space *> A.takeText pure $ DCSendToGroupOwner groupId displayName msg + DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP @@ -249,6 +253,7 @@ directoryCmdTag = \case DCListPendingGroups _ -> "pending" DCShowGroupLink {} -> "link" DCSendToGroupOwner {} -> "owner" + DCInviteOwnerToGroup {} -> "invite" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 7f02a580e6..a62939b6ac 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -15,26 +15,29 @@ import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) -import Simplex.Chat.Options (ChatOpts (..), ChatCmdLog (..), CoreChatOpts, coreChatOptsP) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, adminUsers :: [KnownContact], superUsers :: [KnownContact], + ownersGroup :: Maybe KnownGroup, directoryLog :: Maybe FilePath, serviceName :: T.Text, + runCLI :: Bool, searchResults :: Int, testing :: Bool } directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts -directoryOpts appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +directoryOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName adminUsers <- option parseKnownContacts ( long "admin-users" <> metavar "ADMIN_USERS" + <> value [] <> help "Comma-separated list of admin-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) superUsers <- @@ -44,6 +47,14 @@ directoryOpts appDir defaultDbFileName = do <> metavar "SUPER_USERS" <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) + ownersGroup <- + optional $ + option + parseKnownGroup + ( long "owners-group" + <> metavar "OWNERS_GROUP" + <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" + ) directoryLog <- Just <$> strOption @@ -58,22 +69,29 @@ directoryOpts appDir defaultDbFileName = do <> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)" <> value "SimpleX-Directory" ) + runCLI <- + switch + ( long "run-cli" + <> help "Run directory service as CLI" + ) pure DirectoryOpts { coreOptions, adminUsers, superUsers, + ownersGroup, directoryLog, serviceName = T.pack serviceName, + runCLI, searchResults = 10, testing = False } getDirectoryOpts :: FilePath -> FilePath -> IO DirectoryOpts -getDirectoryOpts appDir defaultDbFileName = +getDirectoryOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> directoryOpts appDir defaultDbFileName) + (helper <*> versionOption <*> directoryOpts appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start SimpleX Directory Service with DB_FILE, DIRECTORY_FILE and SUPER_USERS options") where versionStr = versionString versionNumber diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index afcdb233e8..ed51371be3 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -9,6 +9,7 @@ module Directory.Service ( welcomeGetOpts, directoryService, + directoryServiceCLI, ) where @@ -17,7 +18,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad -import Data.Maybe (fromMaybe, maybeToList) +import Data.List (find, intercalate) +import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) @@ -36,6 +38,8 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Terminal (terminalChatConfig) +import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) @@ -71,39 +75,68 @@ newServiceState = do welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" + opts@DirectoryOpts {coreOptions, testing, superUsers, adminUsers, ownersGroup} <- getDirectoryOpts appDir "simplex_directory_service" unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions + putStrLn $ knownContacts "superuser" superUsers + putStrLn $ knownContacts "admin user" adminUsers + putStrLn $ case ownersGroup of + Nothing -> "No owner's group" + Just KnownGroup {groupId, localDisplayName = n} -> "Owners' group: " <> knownName groupId n pure opts + where + knownContacts userType = \case + [] -> "No " <> userType <> "s" + cts -> show (length cts) <> " " <> userType <> "(s): " <> intercalate ", " (map knownContact cts) + knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n + knownName i n = show i <> ":" <> T.unpack (viewName n) + +directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI st opts = do + env <- newServiceState + eventQ <- newTQueueIO + let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) + race_ + (simplexChatCLI' terminalChatConfig {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) + (processEvents eventQ env) + where + processEvents eventQ env = forever $ do + (cc, resp) <- atomically $ readTQueue eventQ + u_ <- readTVarIO (currentUser cc) + forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults, testing} user@User {userId} cc = do +directoryService st opts@DirectoryOpts {testing} user cc = do initializeBotAddress' (not testing) cc env <- newServiceState race_ (forever $ void getLine) . forever $ do (_, _, resp) <- atomically . readTBQueue $ outputQ cc - forM_ (crDirectoryEvent resp) $ \case - DEContactConnected ct -> deContactConnected ct - DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole - DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner - DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup - DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role - DEServiceRoleChanged g role -> deServiceRoleChanged g role - DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g - DEContactLeftGroup ctId g -> deContactLeftGroup ctId g - DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g - DEGroupDeleted _g -> pure () - DEUnsupportedMessage _ct _ciId -> pure () - DEItemEditIgnored _ct -> pure () - DEItemDeleteIgnored _ct -> pure () - DEContactCommand ct ciId (ADC sUser cmd) -> do - logInfo $ "command received " <> directoryCmdTag cmd - case sUser of - SDRUser -> deUserCommand env ct ciId cmd - SDRAdmin -> deAdminCommand ct ciId cmd - SDRSuperUser -> deSuperUserCommand ct ciId cmd - DELogChatResponse r -> logInfo r + directoryServiceEvent st opts env user cc resp + +directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () +directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = + forM_ (crDirectoryEvent event) $ \case + DEContactConnected ct -> deContactConnected ct + DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole + DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner + DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role + DEServiceRoleChanged g role -> deServiceRoleChanged g role + DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g + DEContactLeftGroup ctId g -> deContactLeftGroup ctId g + DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g + DEGroupDeleted g -> deGroupDeleted g + DEUnsupportedMessage _ct _ciId -> pure () + DEItemEditIgnored _ct -> pure () + DEItemDeleteIgnored _ct -> pure () + DEContactCommand ct ciId (ADC sUser cmd) -> do + logInfo $ "command received " <> directoryCmdTag cmd + case sUser of + SDRUser -> deUserCommand ct ciId cmd + SDRAdmin -> deAdminCommand ct ciId cmd + SDRSuperUser -> deSuperUserCommand ct ciId cmd + DELogChatResponse r -> logInfo r where withAdminUsers action = void . forkIO $ do forM_ superUsers $ \KnownContact {contactId} -> action contactId @@ -153,7 +186,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe processInvitation :: Contact -> GroupInfo -> IO () processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone sendMessage cc ct $ case r of CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" @@ -417,8 +450,16 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." - deUserCommand :: ServiceState -> Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () - deUserCommand env@ServiceState {searchRequests} ct ciId = \case + deGroupDeleted :: GroupInfo -> IO () + deGroupDeleted g = do + logInfo $ "group removed " <> viewGroupName g + withGroupReg g "group removed" $ \gr -> do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." + + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand ct ciId = \case DCHelp -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ @@ -446,7 +487,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search Nothing -> showAllGroups where - showAllGroups = deUserCommand env ct ciId DCAllGroups + showAllGroups = deUserCommand ct ciId DCAllGroups DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent DCSubmitGroup _link -> pure () @@ -561,8 +602,15 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ approved <> " and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." - sendReply "Group approved!" - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> fromMaybe "" invited Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Just GRSContactNotOwner -> replyNotApproved "user is not an owner." Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin @@ -620,10 +668,20 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do notifyOwner gr msg - owner_ <- getContact cc dbContactId - let ownerInfo = "the owner of the group " <> groupRef - ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - sendReply $ "Forwarded to " <> maybe "" ownerName owner_ <> ownerInfo + owner <- groupOwnerInfo groupRef dbContactId + sendReply $ "Forwarded to " <> owner + DCInviteOwnerToGroup groupId gName -> case ownersGroup of + Just og@KnownGroup {localDisplayName = ogName} -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + let groupRef = groupReference' groupId gName + owner <- groupOwnerInfo groupRef ctId + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited + sendReply $ "you" <> invited + Left err -> sendReply err + Nothing -> sendReply "owners' group is not specified" DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -641,6 +699,29 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe ct_ <- getContact cc dbContactId let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ sendGroupInfo ct gr dbGroupId $ Just ownerStr + inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a + inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = + sendChatCmd cc (APIListMembers ogId) >>= \case + CRGroupMembers _ (Group _ ms) + | alreadyMember ms -> cont $ Left "Owner is already a member of owners' group" + | otherwise -> do + sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case + CRSentGroupInvitation {} -> do + printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group" + cont $ Right () + r -> contErr r + r -> contErr r + where + alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + contErr r = do + let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r + putStrLn $ T.unpack err + cont $ Left err + groupOwnerInfo groupRef dbContactId = do + owner_ <- getContact cc dbContactId + let ownerInfo = "the owner of the group " <> groupRef + ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " + pure $ maybe "" ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd @@ -688,7 +769,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe sendComposedMessage cc ct Nothing $ MCText text getContact :: ChatController -> ContactId -> IO (Maybe Contact) -getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) (CPLast 0) Nothing) +getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) Nothing (CPLast 0) Nothing) where resp :: ChatResponse -> Maybe Contact resp = \case diff --git a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md index 502a42c559..339fab4d16 100644 --- a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md +++ b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md @@ -72,7 +72,7 @@ This is a small but important change - you can now see who reacted to your messa ### Improving notifications in iOS app -iOS notifications in a decentralized network is a complex problems. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. +iOS notifications in a decentralized network is a complex problem. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. We solved several problems of notification delivery in this release: - messaging servers no longer lose notifications while notification servers are restarted. diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..ffa322aafe --- /dev/null +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -0,0 +1,142 @@ +--- +layout: layouts/article.html +title: "SimpleX network: large groups and privacy-preserving content moderation" +date: 2025-01-14 +preview: "This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption." +image: images/20250114-locked-books.jpg +permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: large groups and privacy-preserving content moderation + +**Published:** Jan 14, 2025 + + + +Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect — there is a way to prevent abuse and distribution of illegal content without any compromises to users' privacy and security of end-to-end encryption. + +Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse — because our private data will become available to criminals. + +So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for, and who is currently in the office, that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. + +This post answers these questions: +- Why [large groups on SimpleX network](#large-groups-on-simplex-network) don't work well? +- How do we plan to [make them scale](#can-large-groups-scale)? +- How do [group owners prevent abuse](#preventing-abuse-with-anonymous-participation) when people participate anonymously? +- How do server operators [prevent abuse of their servers](#preventing-server-abuse-without-compromising-e2e-encryption) and [how these measures will evolve](#privacy-preserving-content-moderation) without any compromises to privacy and end-to-end encryption? +- Which [privacy and security improvements](#privacy-and-security-improvements-we-plan-this-year) we plan this year? + +## Large groups on SimpleX network + +When we designed groups, we expected them to be used primarily for small groups where people know each other, with not more than 100 or so members. + +But we learnt that people want to participate in public discussions remaining anonymous — it protects their freedom of speech. As an experiment, we are curating a small [directory of groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). + +## Can large groups scale? + +Currently the groups are fully decentralized, and every time you send the message to some group your client has to send it to each group member, which is very costly for traffic and battery in large groups. + +We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members — these members are "super-peers". + +We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have a better control and ownership of the group — e.g., if we decide to remove our super peer from the group, it will continue to function thanks to your super-peer re-broadcasting messages. + +This new design improves both privacy of group participation and censorship resistance of the groups, and also makes abusing the group harder. + +## Preventing abuse with anonymous participation + +All public discussions are abused by spammers and trolls, whether anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. + +As support for large groups improves, we expect that the attempts to abuse may increase too, unless we add better moderation capabilities in advance. + +v6.3 will add ability of the group members to send reports to the group owners and administrators — the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. + +Other features that we plan to add this year to improve both usability and safety of the groups: +- message comments — some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. +- ability to limit the maximum number of messages the members can send per day. +- ability to pre-moderate messages before they can be seen by all members. +- "knocking" — approving new members before they can join the group. +- sub-groups — smaller conversations with the same members. + +## Preventing server abuse without compromising e2e encryption + +Some categories of content may be prohibited by servers operators. An extreme case would be child sexual abuse materials (CSAM). + +Many people believe that when conversation is end-to-end encrypted, the problem is unsolvable. This incorrect belief is used by unscrupulous lobbyists and politicians who attempt to mandate various types of content scanning under the guise of preventing CSAM distribution. + +We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. + +An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act on it and to remove CSAM from their services before it became an epidemic. We see it as a very important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. + +When we receive a user complaint about CSAM shared in any group, we remove the files and, in some cases, the links to join the group from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. + +How does it work? Let's go over the process step by step. + +1. A user discovered the link to join the group that distributes CSAM and sent a complaint to our support email address or via the app to [SimpleX Chat team](simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) contact. + +2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store these files. + +3. Once the servers receive the file identifiers from the bot, they block the files. + +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes — they are securely locked, and sent in chunks, across multiple servers. But if the bot that joined the group provides the address of the particular file, the server can delete this file. It doesn't allow the servers to access any other files. + +In this way, the moderation is possible without any content scanning, and it preserves privacy and security of end-to-end encryption. + +## Privacy-preserving content moderation + +Right now, when we act on user complaints, we delete uploaded files or the links to join the groups from our servers, and to the users it looks as if something stopped working. + +We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. This will improve transparency of moderation and reliability of the network. + +Later this year we plan to do more than that — client-side restrictions on the clients that violated conditions of use by uploading prohibited content. + +How would it work? When the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. + +In this way, the servers can restrict the future actions of the users who violate the conditions of use, while preserving privacy and security of the users and content – even of those users who violated the conditions. + +We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact. Users asked the questions below. + +**Can't users modify their clients code to circumvent these restrictions?** + +Yes, they can, but for this to work both sender and recipient would have to modify their clients. It's technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. + +So these measures would be effective, even though they can be in theory circumvented, as any restrictions can be. + +Other services that identify users reduce abuse by blocking the user account. It is even easier to circumvent than changing the client code, and yet these measures reduce abuse. + +**Can't users use other servers?** + +Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. + +That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. It also prevents the possibility of abuse by server operators. + +**Wouldn't these measures be abused?** + +With the proposed changes, server operators will only be able to prevent uploads to their own servers, which prevents any impact on other communications. + +In the future we plan to increase the resilience to any server malfunction or abuse by using multiple different servers with each contact. + +If servers were to apply any upload restrictions unreasonably, the users would simply stop using them. + +At the same time, server operators need to have technical means to protect their servers from users' abuse, and the proposed client-side restrictions achieve it. + +**What additional measures are considered?** + +We published other technical ideas that could be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). None of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. + +We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), because it violates our right to privacy, and it would result in huge increase of online crime. + +The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove this content. It is also possible to moderate conversations that users made publicly accessible. + +## Send us comments and questions + +Let us know any comments and feedback to the proposed measures. This is still an evolving design, and it won't be implemented until later this year. + +Your comments will help to find the right balance between users' and server operators' requirements. + +## Privacy and security improvements we plan this year + +To increase privacy and security we plan to add this year: +- quantum-resistant e2e encryption in small groups. +- receiving proxy for files, to protect users IP addresses and other transport metadata from file senders' servers. + +We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know what you need to make it better. diff --git a/blog/images/20250114-locked-books.jpg b/blog/images/20250114-locked-books.jpg new file mode 100644 index 0000000000..1963b52df5 Binary files /dev/null and b/blog/images/20250114-locked-books.jpg differ diff --git a/cabal.project b/cabal.project index 680ccc8de5..4603560bdf 100644 --- a/cabal.project +++ b/cabal.project @@ -7,12 +7,12 @@ index-state: 2023-12-12T00:00:00Z package cryptostore flags: +use_crypton -constraints: zip +disable-bzip2 +disable-zstd +constraints: zip +disable-bzip2 +disable-zstd, jpeg-turbo +pkgconfig source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f5cef2556b05cf384db284644c112f822407b361 + tag: 23189753751dc52046865ce2d992335495020e91 source-repository-package type: git diff --git a/docs/CLI.md b/docs/CLI.md index 6f56cf6cd3..628fe2a4af 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -120,7 +120,7 @@ git checkout stable # git checkout v5.3.0-beta.8 ``` -`master` is a development branch, it may containt unstable code. +`master` is a development branch, it may contain unstable code. 3. Prepare the system: diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md index 3ce7ed056d..bd0dcabb53 100644 --- a/docs/TRANSPARENCY.md +++ b/docs/TRANSPARENCY.md @@ -1,12 +1,12 @@ --- title: Transparency Reports permalink: /transparency/index.html -revision: 16.07.2024 +revision: 15.01.2025 --- # Transparency Reports -**Updated**: Oct 14, 2024 +**Updated**: Jan 15, 2025 SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. @@ -14,6 +14,8 @@ This page will include any and all reports on requests for user data. *To date, we received none*. +In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law. + Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances. **Helpful resources**: diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 2e94a4f2c2..50d41265f7 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -113,6 +113,12 @@ "properties": { "text": {"type": "string", "metadata": {"comment": "can be empty"}} } + }, + "report": { + "properties": { + "text": {"type": "string", "metadata": {"comment": "can be empty, includes report reason for old clients"}}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + } } }, "metadata": { diff --git a/docs/rfcs/2024-12-28-reports.md b/docs/rfcs/2024-12-28-reports.md new file mode 100644 index 0000000000..729ad47f19 --- /dev/null +++ b/docs/rfcs/2024-12-28-reports.md @@ -0,0 +1,84 @@ +# Content complaints / reports + +## Problem + +Group moderation is a hard work, particularly when members can join anonymously. + +As groups count and size grows, and as we are moving to working large groups, so will the abuse, so we need report function for active groups that would forward the message that members may find offensive or inappropriate or off-topic or violating any rules that community wants to have. + +It doesn't mean that the moderators must censor everything that is reported, and even less so, that it should be centralized (although in our directory our directory bot would also receive these complaints, and would allow us supporting group owners). + +While we have necessary basic features to remove content and block members, we need to simplify identifying the content both to the group owners and to ourselves, when it comes to the groups listed in directory, or for the groups and files hosted on our servers. + +Having simpler way to report content would also improve the perceived safety of the network for the majority of the users. + +## Solution proposal + +"Report" feature on the messages that would highlight this message to all group admins and moderators. + +Group directory service is also an admin (and will be reduced to moderator in the future), so reported content will be visible to us, so that we can both help group owners to moderate their groups and also to remove the group from directory if necessary. + +To the user who have the new version the reports will be sent as a special event, similar to reaction (or it can be simply an extended reaction?) the usual forwarded messages in the same group, but only to moderators (including admins and owners), with additional flag indicating that this is the report. + +In the clients with the new version the reports could be shown as a flag, possibly with the counter, on group messages that were reported, in the same line where we show emojis. + +If we do that these flags will be seen only by moderators and by the user who submitted the report. When the moderator taps the flag, s/he would see the list of user who reported it, together with the reason. + +The downside of the above UX is that it: +- does not solve the problem of highlighting the problem to admins, particularly if them manage many groups. +- creates confusion about who can see the reports. +- further increases data model complexity, as it requires additional table or self-references (as with quotes), as reports can be received prior to the reported content. +- does not allow admins to see the reported content before it is received by them (would be less important with super-peers). + +Alternatively, and it is probably a better option, all reports, both sent by the users and received by moderators across all groups can be shown in the special subview Reports in each group. The report should be shown as the reported message with the header showing the report reason and the reporter. The report should allow these actions: +- moderate the original message, +- navigate to the original message (requires infinite scrolling, so initially will be only supported on Android and desktop), +- connect to the user who sent the report - it should be possible even if the group prohibits direct messages. There are two options how this communication can be handled - either by creating a new connection, and shown as normal contacts, or as comments to the report, and sent in the same group connection. The latter approach has the advantage that the interface would not be clutter the interace. The former is much simpler, so should probably be offered as MVP. + +This additional chat is necessary, as without it it would be very hard to notice the reports, particularly for the people who moderate multiple groups, and even more so - in our group directory and future super peers. + +## Protocol + +**Option 1** + +The special message `x.msg.report` will be sent in the group with this schema: + +```json +{ + "properties": { + "msgId": {"ref": "base64url"}, + "params": { + "properties": { + "msgId": {"ref": "base64url"}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + }, + "optionalProperties": { + "memberId": {"ref": "base64url"}, + "comment": {"type": "string"} + } + } + } +} +``` + +The downside is that it does not include the original message, so that the admin cannot act on it before the message is received. + +**Option 2** + +Message quote with the new content type. + +Pro - backwards compatible (quote would include text repeating the reason). + +Con - allows reporting non-existing messages, or even mis-reporting, but it is the same consideration that applies to all quotes. In this case though the admin might moderate the message they did not see yet, and it can be abused to remove appropriate content, so the UI should show warning "do you trust the reporter, as you did not receive the message yet". Moderation via reports may have additional information to ensure that exactly the reported message is moderated - e.g., the receiving client would check that the hash of the message in moderation event matches the hash of one of the messages in history. Possibly this is unnecessary with the view of migration of groups to super-peers. + +The report itself would be a new message content type where the report reason would be repeated as text, for backward compatibility. + +The option 2 seems to be simpler to implement, backward compatible and also more naturally fitting the protocol design - the report is simply a message with the new type that the old clients would be able to show correctly as the usual quote. + +The new clients would have a special presentation of these messages and also merging them into one - e.g. they can be shown as group events on in a more prominent way, but less prominent than the actual messages, and also merge subsequent reports about the same message. + +Given that the old clients would not be able to differentiate the reports and normal replies, and can inadvertently reply to all, we probably should warn the members submitting the report that some of the moderators are running the old version, and give them a choice - send to all or send only to moderators with the new version (or don't send, in case all admins run the old version). + +Having the conversation with the member about their report probably fits with the future comment feature that we should start adding to the backend and to the UI as well, as there is no reasonable backward compatibility for it, and members with the old clients simply won't see the comments, so we will have to release it in two stages and simply not send comments to the members with the old version. + +The model for the comments is a new subtype of MsgContainer, that references the original message and member, but does not include the full message. diff --git a/docs/rfcs/2024-12-30-content-moderation.md b/docs/rfcs/2024-12-30-content-moderation.md new file mode 100644 index 0000000000..e4f21a2d21 --- /dev/null +++ b/docs/rfcs/2024-12-30-content-moderation.md @@ -0,0 +1,136 @@ +# Evolving content moderation + +## Problem + +As the users and groups grow, and particularly given that we are planning to make large (10-100k members) groups work, the abuse will inevitably grow as well. + +Our current approach to content moderation is the following: +- receive a user complaints about the group that violates content guidelines (e.g., most users who send complaints, send them about relatively rare cases of CSAM distribution). This complaint contains the link to join the group, so it is a public group that anybody can join, and there is no expectation of privacy of communications in this group. +- we forward this complaint to our automatic bot joins this group and validates the complaint. +- if the complaint is valid, and the link is hosted on one of the pre-configured servers, then we can disable the link to join the group. +- in addition to that, the bot automatically deletes all files sent to the group, in case they are uploaded to our servers, via secure SSH connection directly to server control port (we don't expose shell access in this way, only to a limited set of server control port commands). + +The problem of CSAM is small at the moment, compared with the network size, but without moderation it would grow, and we need to be ahead of this problem, so this solution was in place since early 2024 - we wrote about it on social media. + +The limitation of this approach is that nothing prevents users who created such group to create a new one, and communicate the link to the new group to the existing members so they can migrate there. While this whack-a-mole game has been working so far, it will not be sustainable once we add support for large groups, so we need to be ahead of this problem again, and implement more efficient solutions. + +At the same time, the advantage of both this solution and of the proposed one is that it achieves removal of CSAM without compromising privacy in any way. Most CSAM distribution in all communication networks happens in publicly accessible channels, and it's the same for SimpleX network. So while as server operators we cannot access any content, as users, anybody can access it, and we, acting as users can use available information to remove this content without any compromise to privacy in security. + +This is covered in our [Privacy Policy](https://simplex.chat/privacy/). + +## Solution + +The solution to prevent further CSAM distribution by the users who did it requires restricting their activity on the client side, and also preventing migration of blocked group to another group. + +Traditionally, communication networks have some form of identification on the server side, and that identification is used to block offending users. + +Innovative SimpleX network design removed the need for persistent user identification of users, and many users see it as an unsolvable dilemma - if we cannot identify the users, then we cannot restrict their actions. + +But it is not true. In the same way we already impose restriction on the sent file size, limiting it to 1gb only on the client-side, we can restrict any user actions on the client side, without having any form of user identification, and without knowing how many users were blocked - we would only know how many blocking actions we applied, but we would not have any information about whether they were applied to one or to many users, in the same way as we don't know whether multiple messaging queues are controlled by one or by multiple users. + +The usual counter-argument is that this can be easily circumvented, because the code is open-source, and the users can modify it, so this approach won't work. While this argument premise is correct, the conclusion that this solution won't be effective is incorrect for two reasons: +- most users are either unable or unwilling to invest time into modifying code. This fact alone makes this solution effective in absolute majority of cases. +- any restriction on communication can be applied both on sending and on receiving client, without the need to identify either of these clients. We already do it with 1gb file restriction - e.g., even if file sender modifies their client to allow sending larger files, most of the recipients won't be able to receive this file anyway, as their clients also restrict the size of file that can be received to 1gb. + +For the group that is blocked to continue functioning, not only message senders have to modify their clients, but also message recipients, which won't happen in the absence of ability to communicate in disabled group. Such groups will only be able to function in an isolated segment of the network, when all users use modified clients and with self-hosted servers, which is outside of our zone of any moral and any potential legal responsibility (while we do not have any responsibility for user-generated content under the existing laws, there are requirements we have to comply with that exist outside of law, e.g. requirements of application stores). + +## Potential changes + +This section is the brain-dump of technically possible changes for the future. They will not be implemented all at once, and this list is neither exhaustive, as we or our users can come up with better ideas, nor committed - some of the ideas below may never be implemented. So these ideas are only listed as technical possibilities. + +Our priority is to continue being able to prevent CSAM distribution as network and groups grow, while doing what is reasonable and minimally possible, to save our costs, to avoid any disruption to the users, and to avoid the reduction in privacy and security - on the opposite, we are planning multiple privacy and security improvements in 2025. + +### Mark files and group links as blocked on the server, with the relevant client action + +Add additional protocol command `BLOCK` that would contain the blocking reason that will be presented to the users who try to connect to the link or to download the file. This would differentiate between "not working" scenarios, when file simply fails to download, and "blocked" scenario, and this simple measure would already reduce any prohibited usage of our servers. This change is likely to be implemented in the near future, to make users aware that we are actively moderating illegal content on the network, to educate users about how we do it without any compromise to their privacy and security, and to increase trust in network reliability, as currently our moderation actions are perceived as "something is broken" by affected users. + +### Extend blocking records on files to include client-side restrictions, and apply them to the client who received this blocking record. + +E.g., the client of the user who uploaded the file would periodically check who this file was received by (this functionality currently does not exist), and during this check the client may find out that the file was blocked. When client finds it out it may do any of the following: +- show a warning that the file violated allowed usage conditions that user agreed to. +- apply restrictions, whether temporary or permanent, to upload further files to servers of this operator only (it would be inappropriate to apply wider restrictions - so we appreciate this comment made by one of the users during the consultation). In case we decide that permanent restrictions should be applied, we could also program the ability to appeal this decision to support team and lift it via unblock code - without the need to have any user identification. + +The downside of this approach is that the client would have to check the file after it is uploaded, which may create additional traffic. But at the same time it would provide file delivery receipts, so overall it could be a valuable, although substantial, change. + +To continue with the file, the clients of the users who attempt to receive the file after it was blocked could do one of the following, depending on the blocking record: +- see the warning that the file is blocked. If CSAM was sent in a group that is not distributing CSAM, this adds comfort and the feeling of safety. +- block image preview, in the same way we block avatars of blocked members. +- users can configure automatic deletion of messages with blocked files. +- refuse, temporarily or permanently, to receive future files and/or messages from this group member. Permanent restriction may be automatically lifted once the member's client presents the proof of being unblocked by server operator. + +Applying the restrictions on the receiving side is technically simpler, and requires only minimal protocol changes mentioned above. + +While file senders can circumvent client side restrictions applied by server operators, these measures can be effective, because the recipients would also have to circumvent them, which is much less likely to happen in a coordinated way. + +The upside of this approach is that it does not compromise users' privacy in any way, and it does not interfere with users rights too. A user voluntarily accepted the Conditions of Use that prohibit upload of illegal content to our servers, so it is in line with the agreement for us to enforce these conditions and restrict functionality in case of conditions being violated. At the same time it would be inappropriate for us to restrict the ability to upload files to the servers of 3rd party operators that are not pre-configured in the app - only these operators should be able to restrict uploads to their servers. + +It also avoids the need for any scanning of content, whether client- or server-side, that would also be an infringement on the users right to privacy under European Convention of Human Rights, article 8. It also makes it unnecessary to identify users, contrary to common belief that to restrict users one needs to identify them. + +In the same way the network design allows delivering user messages without any form of user identification on the network protocol level, which is the innovation that does not exist in any other network, we can apply client-side restrictions on user activities without the need to identify a user. So if the block we apply to a specific piece of content results in client-side upload/download restrictions, all we would know is how many times this restriction was applied, but not to how many users - multiple blocked files could have been all uploaded by one user or by multiple users, but this is not the knowledge that is required to restrict further abuse of our servers and violation of condition of use. Again, this is an innovative approach to moderation that is not present in any of the networks, that allows us both to remain in compliance with the contractual obligations (e.g., with application store owners) and any potential legal obligation (even though the legal advice we have is that we do not have obligation to moderate content, as we are not providing communication services), once it becomes a bigger issue. + +### Extend blocking records on links to include client-side restrictions, and apply them to the clients who received this blocking record. + +Similarly to files, once the link to join the group is blocked, both the owner's client and all members' clients can impose (technically) any of the following restrictions. + +For the owner: +- restrict, temporarily or permanently, ability to create public groups on the servers of the operator (or group of operators, in case of pre-configured operators) who applied this blocking record. +- restrict, temporarily or permanently, ability to upload files to operator's servers. +- restrict, temporarily or permanently, sending any messages to operator's servers, not only in the blocked group. + +For all group members: +- restrict, temporarily or permanently, ability to send and receive messages in the blocked group. + +For the same reason as with files, this measure will be an effective deterrence, even though the code is open-source. + +While full blocking may be seen as draconian, for the people who repeatedly violate the conditions of use, ignoring temporary or limited restrictions, it may be appropriate. The tracking of repeat violations of conditions also does not require any user identification and can be done fully on the client side, with sufficient efficiency. + +### Implement ability to submit reports to group owners and moderators + +This is covered under a [separate RFC](./2024-12-28-reports.md) and is currently in progress. This would improve the ability of group owners to moderate their groups, and would also improve our ability to moderate all listed groups, both manually and automatically, as Directory Service has moderation rights. + +### Implement ability to submit reports to 3rd party server operators + +While users already can send reports to ourselves directly via the app, sending them to other server operators requires additional steps from the users. + +This function would allow sending reports to any server operator directly via the app, to the address sent by the server during the initial connection. + +Server operators may be then offered efficient interfaces in the clients to manage these complaints and to apply client-side restrictions to the users who violate the conditions. + +### Blacklist servers who refuse to remove CSAM from receiving any traffic from our servers + +We cannot and should not enforce that 3rd party server operators remove CSAM from their servers. We will only be recommending it and providing tools to simplify it. + +But we can, technically, implement block-lists of servers so that the users who need to send messages to these servers would not be able to do that via our servers. + +We also can require mandatory server identification to requests to proxy messages via client certificates of the server that could be validated via a reverse connection, and also block incoming traffic from these servers. + +While both these measures are undesirable and would result in network fragmentation, they are technically possible. Similar restrictions already happen in fediverse networks, and they are effective. + +## Actual planned changes + +To summarize, the changes that are planned in the near future: + +- client-side notifications that files or group links were blocked (as opposed to show error, creating an impression that something is not working). +- [content reports](./2024-12-28-reports.md) to group owners and moderators. +- additional short notice about conditions of use that apply to file uploads prior to the first upload. + +Additional simple changes that are considered: + +- applying client-side restriction to create new public groups on operator's servers on admins of blocked groups (do not confuse that with the groups that we decided not to list in our directory, or decided to remove from our directory - this is not blocking that is being discussed here). +- if the group link was registered via directory service, we can prevent further registration of public groups in directory service for this user by, communicating that this link is blocked to directory service. +- preventing any communication in blocked groups. + +To clarify, all these restrictions are considered only for the groups that were created primarily to distribute or to promote CSAM content, they won't apply in cases some group members maliciously posted illegal content in a public group - in which case they will only be applied to this member, helping group owners to moderate. + +We will continue moderating the content as we do now, and as long as CSAM distribution is prevented, we may not need additional measures listed here. + +At the same time, we are committed to make it impossible to distribute CSAM in the part of SimpleX network that we or any other pre-configured operators operate. + +We are also committed to achieve this goal without any reduction in privacy and security even for the affected users. E.g., unless there is an enforceable order, we will not be recording any information identifying the user, such as IP address, because it may inadvertently affect the users whose content was flagged by mistake. + +Our ultimate commitment, and our business is to provide private and secure communication to the users who comply with conditions of use, and to prevent mass-scale surveillance of non-suspects (which is a direct violation of European Convention of Human Rights). + +Privacy and security of the network will further improve in 2025, as we plan: +- adding post-quantum encryption to small groups. +- adding proxying during file reception from unknown (or all) servers. +- adding scheduled and delayed re-broadcasts in large groups, to frustrate timing attacks that could otherwise allow identifying users who send messages to groups. diff --git a/package.yaml b/package.yaml deleted file mode 100644 index de88891aa7..0000000000 --- a/package.yaml +++ /dev/null @@ -1,190 +0,0 @@ -name: simplex-chat -version: 6.2.0.7 -#synopsis: -#description: -homepage: https://github.com/simplex-chat/simplex-chat#readme -license: AGPL-3 -author: simplex.chat -maintainer: chat@simplex.chat -copyright: 2020-22 simplex.chat -category: Web, System, Services, Cryptography -extra-source-files: - - README.md - - PRIVACY.md - - cabal.project - -dependencies: - - aeson == 2.2.* - - ansi-terminal >= 0.10 && < 0.12 - - async == 2.2.* - - attoparsec == 0.14.* - - base >= 4.7 && < 5 - - base64-bytestring >= 1.0 && < 1.3 - - composition == 1.0.* - - constraints >= 0.12 && < 0.14 - - containers == 0.6.* - - crypton == 0.34.* - - data-default >= 0.7 && < 0.8 - - directory == 1.3.* - - direct-sqlcipher == 2.3.* - - email-validate == 2.3.* - - exceptions == 0.10.* - - filepath == 1.4.* - - file-embed == 0.0.15.* - - http-types == 0.12.* - - http2 >= 4.2.2 && < 4.3 - - memory == 0.18.* - - mtl >= 2.3.1 && < 3.0 - - network >= 3.1.2.7 && < 3.2 - - network-transport == 0.5.6 - - optparse-applicative >= 0.15 && < 0.17 - - random >= 1.1 && < 1.3 - - record-hasfield == 1.0.* - - scientific ==0.3.7.* - - simple-logger == 0.1.* - - simplexmq >= 5.0 - - socks == 0.6.* - - sqlcipher-simple == 0.4.* - - stm == 2.5.* - - terminal == 0.2.* - - time == 1.12.* - - tls >= 1.9.0 && < 1.10 - - unliftio == 0.2.* - - unliftio-core == 0.2.* - - uuid == 1.3.* - - zip == 2.0.* - - JuicyPixels - - JuicyPixels-jpeg-turbo - - JuicyPixels-stbir - -flags: - swift: - description: Enable swift JSON format - manual: True - default: False - -when: - - condition: flag(swift) - cpp-options: - - -DswiftJSON - - condition: impl(ghc >= 9.6.2) - dependencies: - - bytestring == 0.11.* - - process == 1.6.* - - template-haskell == 2.20.* - - text >= 2.0.1 && < 2.2 - - condition: impl(ghc < 9.6.2) - dependencies: - - bytestring == 0.10.* - - process >= 1.6 && < 1.6.18 - - template-haskell == 2.16.* - - text >= 1.2.4.0 && < 1.3 - -library: - source-dirs: src - -executables: - simplex-chat: - source-dirs: apps/simplex-chat - main: Main.hs - dependencies: - - simplex-chat - - network == 3.1.* - - websockets == 0.12.* - ghc-options: - - -threaded - - simplex-bot: - source-dirs: apps/simplex-bot - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-bot-advanced: - source-dirs: apps/simplex-bot-advanced - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-broadcast-bot: - source-dirs: - - apps/simplex-broadcast-bot - - apps/simplex-broadcast-bot/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-directory-service: - source-dirs: - - apps/simplex-directory-service - - apps/simplex-directory-service/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - -tests: - simplex-chat-test: - source-dirs: - - tests - - apps/simplex-broadcast-bot/src - - apps/simplex-directory-service/src - main: Test.hs - when: - - condition: impl(ghc >= 9.6.2) - dependencies: - - hspec == 2.11.* - - condition: impl(ghc < 9.6.2) - dependencies: - - hspec == 2.7.* - dependencies: - - QuickCheck == 2.14.* - - simplex-chat - - async == 2.2.* - - deepseq == 1.4.* - - generic-random == 1.5.* - - network == 3.1.* - - silently == 1.2.* - - stm == 2.5.* - ghc-options: - - -threaded - -ghc-options: - # - -haddock - - -O2 - - -Weverything - - -Wno-missing-exported-signatures - - -Wno-missing-import-lists - - -Wno-missed-specialisations - - -Wno-all-missed-specialisations - - -Wno-unsafe - - -Wno-safe - - -Wno-missing-local-signatures - - -Wno-missing-kind-signatures - - -Wno-missing-deriving-strategies - - -Wno-monomorphism-restriction - - -Wno-prepositive-qualified-module - - -Wno-unused-packages - - -Wno-implicit-prelude - - -Wno-missing-safe-haskell-mode - - -Wno-missing-export-lists - - -Wno-partial-fields - - -Wcompat - - -Werror=incomplete-record-updates - - -Werror=incomplete-patterns - - -Werror=missing-methods - - -Werror=incomplete-uni-patterns - - -Werror=tabs - - -Wredundant-constraints - - -Wincomplete-record-updates - - -Wunused-type-patterns - -default-extensions: - - StrictData diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 9b915532f7..8141046d8a 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,63 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-4:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
+ + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-3:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
+ + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-2:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7ae001492d..0ecde8a60e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f5cef2556b05cf384db284644c112f822407b361" = "14w0fji1fdr63w4pjkhlrdiz93p2axghzkb1ykvnrw111by87g8l"; + "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; "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"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b82e2c2c23..46423ba9a8 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.2.0.7 +version: 6.3.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -24,11 +24,15 @@ flag swift manual: True default: False +flag client_postgres + description: Build with PostgreSQL instead of SQLite. + manual: True + default: False + library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings - Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts Simplex.Chat.Call @@ -45,122 +49,6 @@ library Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events - Simplex.Chat.Migrations.M20220101_initial - Simplex.Chat.Migrations.M20220122_v1_1 - Simplex.Chat.Migrations.M20220205_chat_item_status - Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests - Simplex.Chat.Migrations.M20220224_messages_fks - Simplex.Chat.Migrations.M20220301_smp_servers - Simplex.Chat.Migrations.M20220302_profile_images - Simplex.Chat.Migrations.M20220304_msg_quotes - Simplex.Chat.Migrations.M20220321_chat_item_edited - Simplex.Chat.Migrations.M20220404_files_status_fields - Simplex.Chat.Migrations.M20220514_profiles_user_id - Simplex.Chat.Migrations.M20220626_auto_reply - Simplex.Chat.Migrations.M20220702_calls - Simplex.Chat.Migrations.M20220715_groups_chat_item_id - Simplex.Chat.Migrations.M20220811_chat_items_indices - Simplex.Chat.Migrations.M20220812_incognito_profiles - Simplex.Chat.Migrations.M20220818_chat_notifications - Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id - Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items - Simplex.Chat.Migrations.M20220824_profiles_local_alias - Simplex.Chat.Migrations.M20220909_commands - Simplex.Chat.Migrations.M20220926_connection_alias - Simplex.Chat.Migrations.M20220928_settings - Simplex.Chat.Migrations.M20221001_shared_msg_id_indices - Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items - Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id - Simplex.Chat.Migrations.M20221011_user_contact_links_group_id - Simplex.Chat.Migrations.M20221012_inline_files - Simplex.Chat.Migrations.M20221019_unread_chat - Simplex.Chat.Migrations.M20221021_auto_accept__group_links - Simplex.Chat.Migrations.M20221024_contact_used - Simplex.Chat.Migrations.M20221025_chat_settings - Simplex.Chat.Migrations.M20221029_group_link_id - Simplex.Chat.Migrations.M20221112_server_password - Simplex.Chat.Migrations.M20221115_server_cfg - Simplex.Chat.Migrations.M20221129_delete_group_feature_items - Simplex.Chat.Migrations.M20221130_delete_item_deleted - Simplex.Chat.Migrations.M20221209_verified_connection - Simplex.Chat.Migrations.M20221210_idxs - Simplex.Chat.Migrations.M20221211_group_description - Simplex.Chat.Migrations.M20221212_chat_items_timed - Simplex.Chat.Migrations.M20221214_live_message - Simplex.Chat.Migrations.M20221222_chat_ts - Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status - Simplex.Chat.Migrations.M20221230_idxs - Simplex.Chat.Migrations.M20230107_connections_auth_err_counter - Simplex.Chat.Migrations.M20230111_users_agent_user_id - Simplex.Chat.Migrations.M20230117_fkey_indexes - Simplex.Chat.Migrations.M20230118_recreate_smp_servers - Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx - Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id - Simplex.Chat.Migrations.M20230303_group_link_role - Simplex.Chat.Migrations.M20230317_hidden_profiles - Simplex.Chat.Migrations.M20230318_file_description - Simplex.Chat.Migrations.M20230321_agent_file_deleted - Simplex.Chat.Migrations.M20230328_files_protocol - Simplex.Chat.Migrations.M20230402_protocol_servers - Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions - Simplex.Chat.Migrations.M20230420_rcv_files_to_receive - Simplex.Chat.Migrations.M20230422_profile_contact_links - Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages - Simplex.Chat.Migrations.M20230505_chat_item_versions - Simplex.Chat.Migrations.M20230511_reactions - Simplex.Chat.Migrations.M20230519_item_deleted_ts - Simplex.Chat.Migrations.M20230526_indexes - Simplex.Chat.Migrations.M20230529_indexes - Simplex.Chat.Migrations.M20230608_deleted_contacts - Simplex.Chat.Migrations.M20230618_favorite_chats - Simplex.Chat.Migrations.M20230621_chat_item_moderations - Simplex.Chat.Migrations.M20230705_delivery_receipts - Simplex.Chat.Migrations.M20230721_group_snd_item_statuses - Simplex.Chat.Migrations.M20230814_indexes - Simplex.Chat.Migrations.M20230827_file_encryption - Simplex.Chat.Migrations.M20230829_connections_chat_vrange - Simplex.Chat.Migrations.M20230903_connections_to_subscribe - Simplex.Chat.Migrations.M20230913_member_contacts - Simplex.Chat.Migrations.M20230914_member_probes - Simplex.Chat.Migrations.M20230926_contact_status - Simplex.Chat.Migrations.M20231002_conn_initiated - Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash - Simplex.Chat.Migrations.M20231010_member_settings - Simplex.Chat.Migrations.M20231019_indexes - Simplex.Chat.Migrations.M20231030_xgrplinkmem_received - Simplex.Chat.Migrations.M20231107_indexes - Simplex.Chat.Migrations.M20231113_group_forward - Simplex.Chat.Migrations.M20231114_remote_control - Simplex.Chat.Migrations.M20231126_remote_ctrl_address - Simplex.Chat.Migrations.M20231207_chat_list_pagination - Simplex.Chat.Migrations.M20231214_item_content_tag - Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries - Simplex.Chat.Migrations.M20240102_note_folders - Simplex.Chat.Migrations.M20240104_members_profile_update - Simplex.Chat.Migrations.M20240115_block_member_for_all - Simplex.Chat.Migrations.M20240122_indexes - Simplex.Chat.Migrations.M20240214_redirect_file_id - Simplex.Chat.Migrations.M20240222_app_settings - Simplex.Chat.Migrations.M20240226_users_restrict - Simplex.Chat.Migrations.M20240228_pq - Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id - Simplex.Chat.Migrations.M20240324_custom_data - Simplex.Chat.Migrations.M20240402_item_forwarded - Simplex.Chat.Migrations.M20240430_ui_theme - Simplex.Chat.Migrations.M20240501_chat_deleted - Simplex.Chat.Migrations.M20240510_chat_items_via_proxy - Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays - Simplex.Chat.Migrations.M20240528_quota_err_counter - Simplex.Chat.Migrations.M20240827_calls_uuid - Simplex.Chat.Migrations.M20240920_user_order - Simplex.Chat.Migrations.M20241008_indexes - Simplex.Chat.Migrations.M20241010_contact_requests_contact_id - Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id - Simplex.Chat.Migrations.M20241027_server_operators - Simplex.Chat.Migrations.M20241125_indexes - Simplex.Chat.Migrations.M20241128_business_chats - Simplex.Chat.Migrations.M20241205_business_chat_members - Simplex.Chat.Migrations.M20241206_chat_tags Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -168,6 +56,7 @@ library Simplex.Chat.Operators Simplex.Chat.Operators.Conditions Simplex.Chat.Options + Simplex.Chat.Options.DB Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote @@ -185,7 +74,6 @@ library Simplex.Chat.Store.Files Simplex.Chat.Store.Groups Simplex.Chat.Store.Messages - Simplex.Chat.Store.Migrations Simplex.Chat.Store.NoteFolders Simplex.Chat.Store.Profiles Simplex.Chat.Store.Remote @@ -203,6 +91,136 @@ library Simplex.Chat.Types.Util Simplex.Chat.Util Simplex.Chat.View + if flag(client_postgres) + exposed-modules: + Simplex.Chat.Options.Postgres + Simplex.Chat.Store.Postgres.Migrations + Simplex.Chat.Store.Postgres.Migrations.M20241220_initial + else + exposed-modules: + Simplex.Chat.Archive + Simplex.Chat.Options.SQLite + Simplex.Chat.Store.SQLite.Migrations + Simplex.Chat.Store.SQLite.Migrations.M20220101_initial + Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 + Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status + Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests + Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks + Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers + Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images + Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes + Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited + Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields + Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id + Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply + Simplex.Chat.Store.SQLite.Migrations.M20220702_calls + Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id + Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices + Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles + Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications + Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id + Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items + Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias + Simplex.Chat.Store.SQLite.Migrations.M20220909_commands + Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias + Simplex.Chat.Store.SQLite.Migrations.M20220928_settings + Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices + Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items + Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id + Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id + Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files + Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat + Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links + Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used + Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings + Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id + Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password + Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg + Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items + Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted + Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection + Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs + Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description + Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed + Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message + Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts + Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status + Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs + Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter + Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id + Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers + Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx + Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id + Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role + Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles + Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description + Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted + Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol + Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers + Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions + Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive + Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links + Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages + Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions + Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions + Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts + Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts + Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats + Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations + Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts + Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses + Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption + Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange + Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe + Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts + Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes + Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status + Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated + Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash + Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings + Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes + Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received + Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes + Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward + Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control + Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address + Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination + Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag + Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries + Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders + Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update + Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all + Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes + Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id + Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings + Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict + Simplex.Chat.Store.SQLite.Migrations.M20240228_pq + Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id + Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data + Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded + Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme + Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted + Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy + Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays + Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter + Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid + Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order + Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes + Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id + Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id + Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators + Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes + Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats + Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members + Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions + Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags + Simplex.Chat.Store.SQLite.Migrations.M20241230_reports + Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes + Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl other-modules: Paths_simplex_chat hs-source-dirs: @@ -211,10 +229,7 @@ library StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns build-depends: - JuicyPixels - , JuicyPixels-jpeg-turbo - , JuicyPixels-stbir - , aeson ==2.2.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -225,7 +240,6 @@ library , containers ==0.6.* , crypton ==0.34.* , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -233,6 +247,9 @@ library , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 + , JuicyPixels >=3.3.8 && < 3.4 + , JuicyPixels-jpeg-turbo ==0.1.0.0 + , JuicyPixels-stbir ==0.1.0.0 , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 @@ -242,9 +259,8 @@ library , record-hasfield ==1.0.* , scientific ==0.3.7.* , simple-logger ==0.1.* - , simplexmq >=5.0 + , simplexmq >=6.3 , socks ==0.6.* - , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* @@ -256,6 +272,16 @@ library default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if flag(client_postgres) + build-depends: + postgresql-libpq >=0.10.0.0 + , postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* + cpp-options: -DdbPostgres + else + build-depends: + direct-sqlcipher ==2.3.* + , sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -279,64 +305,15 @@ executable simplex-bot StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - JuicyPixels + base >=4.7 && <5 + , directory ==1.3.* + , JuicyPixels ==3.3.* , JuicyPixels-jpeg-turbo , JuicyPixels-stbir - , aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* - , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* - , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* - , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON - if impl(ghc >= 9.6.2) - build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 - if impl(ghc < 9.6.2) - build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + if flag(client_postgres) + cpp-options: -DdbPostgres executable simplex-bot-advanced main-is: Main.hs @@ -348,64 +325,24 @@ executable simplex-bot-advanced StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - JuicyPixels - , JuicyPixels-jpeg-turbo - , JuicyPixels-stbir - , aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* + , JuicyPixels ==3.3.* + , JuicyPixels-jpeg-turbo ==0.1.0.0 + , JuicyPixels-stbir ==0.1.0.0 , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot main-is: Main.hs @@ -420,64 +357,22 @@ executable simplex-broadcast-bot Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - JuicyPixels - , JuicyPixels-jpeg-turbo - , JuicyPixels-stbir - , aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -490,65 +385,25 @@ executable simplex-chat StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - JuicyPixels - , JuicyPixels-jpeg-turbo - , JuicyPixels-stbir - , aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + aeson ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* , websockets ==0.12.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-directory-service main-is: Main.hs @@ -566,63 +421,28 @@ executable simplex-directory-service Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - JuicyPixels - , JuicyPixels-jpeg-turbo - , JuicyPixels-stbir - , aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* + async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test @@ -641,19 +461,17 @@ test-suite simplex-chat-test ChatTests.Local ChatTests.Profiles ChatTests.Utils + JSONFixtures JSONTests LinkPreviewTests MarkdownTests MessageBatching - MobileTests OperatorTests ProtocolTests RandomServers RemoteTests - SchemaDump ValidNames ViewTests - WebRTCTests Broadcast.Bot Broadcast.Options Directory.Events @@ -662,6 +480,11 @@ test-suite simplex-chat-test Directory.Service Directory.Store Paths_simplex_chat + if !flag(client_postgres) + other-modules: + MobileTests + SchemaDump + WebRTCTests hs-source-dirs: tests apps/simplex-broadcast-bot/src @@ -681,60 +504,42 @@ test-suite simplex-chat-test , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* , crypton ==0.34.* - , data-default ==0.7.* , deepseq ==1.4.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* , filepath ==1.4.* , generic-random ==1.5.* , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON + if flag(client_postgres) + build-depends: + postgresql-simple ==0.7.* + cpp-options: -DdbPostgres + else + build-depends: + sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* + , hspec ==2.11.* , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* + , hspec ==2.7.* , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 - if impl(ghc >= 9.6.2) - build-depends: - hspec ==2.11.* - if impl(ghc < 9.6.2) - build-depends: - hspec ==2.7.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7a23792a6a..bf07e4ae51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -20,7 +20,6 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Data.Bifunctor (bimap, second) -import Data.ByteArray (ScrubbedBytes) import Data.List (partition, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L @@ -32,6 +31,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -41,8 +41,9 @@ import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) @@ -182,10 +183,10 @@ fluxXFTPServers = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations +createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) +createChatDatabase chatDbOpts confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) confirmMigrations + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) confirmMigrations pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 1efa69fad4..23b5f2ddad 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -25,6 +25,8 @@ data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Sho data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) +data OpenLinksSetting = OLSYes | OLSNo | OLSAsk deriving (Show) + data AppSettings = AppSettings { appPlatform :: Maybe AppPlatform, networkConfig :: Maybe NetworkConfig, @@ -33,6 +35,7 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, + privacyChatListOpenLinks :: Maybe OpenLinksSetting, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -83,6 +86,7 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, + privacyChatListOpenLinks = Just OLSAsk, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -120,6 +124,7 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, + privacyChatListOpenLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -157,6 +162,7 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, + privacyChatListOpenLinks = p privacyChatListOpenLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -197,6 +203,8 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) $(JQ.deriveJSON (enumJSON $ dropPrefix "NPA") ''NetworkProxyAuth) +$(JQ.deriveJSON (enumJSON $ dropPrefix "OLS") ''OpenLinksSetting) + $(JQ.deriveJSON defaultJSON ''NetworkProxy) $(JQ.deriveToJSON defaultJSON ''AppSettings) @@ -210,6 +218,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" + privacyChatListOpenLinks <- p "privacyChatListOpenLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -244,6 +253,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, + privacyChatListOpenLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 218d1e1f2e..2cbc941b44 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -11,7 +11,6 @@ module Simplex.Chat.Archive deleteStorage, sqlCipherExport, sqlCipherTestKey, - archiveFilesFolder, ) where @@ -28,7 +27,8 @@ import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Chat.Util () import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, keyString, sqlString, storeKey) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, keyString, sqlString, storeKey) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..)) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -74,7 +74,7 @@ importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir fs@StorageFiles {chatStore, agentStore, filesPath, assetsPath} <- storageFiles - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs backup `withDBs` fs copyFile (dir archiveChatDbFile) $ dbFilePath chatStore copyFile (dir archiveAgentDbFile) $ dbFilePath agentStore @@ -111,7 +111,7 @@ copyValidDirectoryFiles isFileError fromDir toDir = do Nothing -> (copyDirectoryFile f $> fileErrs) `E.catch` \(e :: E.SomeException) -> addErr $ show e - Just e -> addErr e + Just e -> addErr e where addErr e = pure $ AEFileError f e : fileErrs copyDirectoryFile f = do @@ -122,7 +122,7 @@ copyValidDirectoryFiles isFileError fromDir toDir = do deleteStorage :: CM () deleteStorage = do fs <- lift storageFiles - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs remove `withDBs` fs mapM_ removeDir $ filesPath fs mapM_ removeDir $ assetsPath fs @@ -132,8 +132,8 @@ deleteStorage = do removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles - { chatStore :: SQLiteStore, - agentStore :: SQLiteStore, + { chatStore :: DBStore, + agentStore :: DBStore, filesPath :: Maybe FilePath, assetsPath :: Maybe FilePath } @@ -156,20 +156,20 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D removeExported `withDBs` fs export `withDBs` fs -- closing after encryption prevents closing in case wrong encryption key was passed - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs (moveExported `withStores` fs) `catchChatError` \e -> (restore `withDBs` fs) >> throwError e where backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - checkEncryption SQLiteStore {dbKey} = do + checkEncryption DBStore {dbKey} = do enc <- maybe True (not . BA.null) <$> readTVarIO dbKey when (enc && BA.null key) $ throwDBError DBErrorEncrypted when (not enc && not (BA.null key)) $ throwDBError DBErrorPlaintext exported = (<> ".exported") removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) - moveExported SQLiteStore {dbFilePath = f, dbKey} = do + moveExported DBStore {dbFilePath = f, dbKey} = do renameFile (exported f) f atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey) export f = do @@ -219,5 +219,5 @@ sqlCipherTestKey (DBEncryptionKey key) = do withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) -withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b +withStores :: Monad m => (DBStore -> m b) -> StorageFiles -> m b action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index 4555bb9fee..0c902d8566 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -1,5 +1,7 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Bot.KnownContacts where @@ -9,8 +11,8 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Options.Applicative +import Simplex.Chat.Library.Commands (displayNameP) import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Util (safeDecodeUtf8) data KnownContact = KnownContact { contactId :: Int64, @@ -18,8 +20,13 @@ data KnownContact = KnownContact } deriving (Eq) +data KnownGroup = KnownGroup + { groupId :: Int64, + localDisplayName :: Text + } + knownContactNames :: [KnownContact] -> Text -knownContactNames = T.intercalate ", " . map (("@" <>) . localDisplayName) +knownContactNames = T.intercalate ", " . map (("@" <>) . (\KnownContact {localDisplayName = n} -> n)) parseKnownContacts :: ReadM [KnownContact] parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack @@ -29,5 +36,14 @@ knownContactsP = contactP `A.sepBy1` A.char ',' where contactP = do contactId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + localDisplayName <- displayNameP pure KnownContact {contactId, localDisplayName} + +parseKnownGroup :: ReadM KnownGroup +parseKnownGroup = eitherReader $ parseAll knownGroupP . encodeUtf8 . T.pack + +knownGroupP :: A.Parser KnownGroup +knownGroupP = do + groupId <- A.decimal <* A.char ':' + localDisplayName <- displayNameP + pure KnownGroup {groupId, localDisplayName} diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 882ec8ccd0..3b1f28dd27 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,9 +1,12 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -18,9 +21,9 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) @@ -90,6 +93,9 @@ data CallState newtype CallId = CallId ByteString deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField CallId where toField (CallId m) = toField $ Binary m instance StrEncoding CallId where strEncode (CallId m) = strEncode m @@ -103,10 +109,6 @@ instance ToJSON CallId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField CallId where fromField f = CallId <$> fromField f - -instance ToField CallId where toField (CallId m) = toField m - data RcvCallInvitation = RcvCallInvitation { user :: User, contact :: Contact, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ffefddd701..ac4b50ed9f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,8 +46,6 @@ import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) -import Database.SQLite.Simple (SQLError) -import qualified Database.SQLite.Simple as SQL import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -73,9 +71,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWo import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, UpMigration) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -96,6 +94,11 @@ import System.IO (Handle) import System.Mem.Weak (Weak) import qualified UnliftIO.Exception as E import UnliftIO.STM +#if !defined(dbPostgres) +import Database.SQLite.Simple (SQLError) +import qualified Database.SQLite.Simple as SQL +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif versionNumber :: String versionNumber = showVersion SC.version @@ -203,7 +206,7 @@ defaultInlineFilesConfig = receiveInstant = True -- allow receiving instant files, within receiveChunks limit } -data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} +data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore} data ChatController = ChatController { currentUser :: TVar (Maybe User), @@ -213,7 +216,7 @@ data ChatController = ChatController firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), - chatStore :: SQLiteStore, + chatStore :: DBStore, chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted random :: TVar ChaChaDRG, eventSeq :: TVar Int, @@ -283,20 +286,22 @@ data ChatCommand | APISetAppFilePaths AppFilePathsConfig | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool +#if !defined(dbPostgres) | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig - | APISaveAppSettings AppSettings - | APIGetAppSettings (Maybe AppSettings) | APIDeleteStorage | APIStorageEncryption DBEncryptionConfig | TestStorageEncryption DBEncryptionKey + | SlowSQLQueries +#endif | ExecChatStoreSQL Text | ExecAgentStoreSQL Text - | SlowSQLQueries + | APISaveAppSettings AppSettings + | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat ChatRef ChatPagination (Maybe String) + | APIGetChat ChatRef (Maybe MsgContentTag) ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} @@ -306,6 +311,8 @@ data ChatCommand | APIUpdateChatTag ChatTagId ChatTagData | APIReorderChatTags (NonEmpty ChatTagId) | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} + | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} + | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) @@ -335,6 +342,7 @@ data ChatCommand | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias + | APISetGroupAlias GroupId LocalAlias | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) @@ -346,7 +354,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole - | APIJoinGroup GroupId + | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIMemberRole GroupId GroupMemberId GroupMemberRole | APIBlockMemberForAll GroupId GroupMemberId Bool | APIRemoveMember GroupId GroupMemberId @@ -372,10 +380,13 @@ data ChatCommand | APIGetUsageConditions | APISetConditionsNotified Int64 | APIAcceptConditions Int64 (NonEmpty Int64) - | APISetChatItemTTL UserId (Maybe Int64) - | SetChatItemTTL (Maybe Int64) + | APISetChatItemTTL UserId Int64 + | SetChatItemTTL Int64 | APIGetChatItemTTL UserId | GetChatItemTTL + | APISetChatTTL UserId ChatRef (Maybe Int64) + | SetChatTTL ChatName (Maybe Int64) + | GetChatTTL ChatName | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | SetNetworkConfig SimpleNetCfg @@ -464,7 +475,7 @@ data ChatCommand | APINewGroup UserId IncognitoEnabled GroupProfile | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole - | JoinGroup GroupName + | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool | RemoveMember GroupName ContactName @@ -556,11 +567,14 @@ allowRemoteCommand = \case SetFilesFolder _ -> False SetRemoteHostsFolder _ -> False APISetEncryptLocalFiles _ -> False +#if !defined(dbPostgres) APIExportArchive _ -> False APIImportArchive _ -> False ExportArchive -> False APIDeleteStorage -> False APIStorageEncryption _ -> False + SlowSQLQueries -> False +#endif APISetNetworkConfig _ -> False APIGetNetworkConfig -> False SetLocalDeviceName _ -> False @@ -580,7 +594,6 @@ allowRemoteCommand = \case DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False - SlowSQLQueries -> False _ -> True data ChatResponse @@ -632,6 +645,7 @@ data ChatResponse | CRChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} | CRReactionMembers {user :: User, memberReactions :: [MemberReaction]} | CRChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} + | CRGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent {user :: User, msgContent :: MsgContent, successes :: Int, failures :: Int, timestamp :: UTCTime} | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} @@ -710,6 +724,7 @@ data ChatResponse | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} + | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactConnecting {user :: User, contact :: Contact} @@ -726,7 +741,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} + | CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -742,8 +757,7 @@ data ChatResponse | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} - | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} + | CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} @@ -758,9 +772,9 @@ data ChatResponse | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} @@ -794,7 +808,11 @@ data ChatResponse | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} +#if !defined(dbPostgres) + | CRArchiveExported {archiveErrors :: [ArchiveError]} + | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} +#endif | CRDebugLocks {chatLockName :: Maybe String, chatEntityLocks :: Map String String, agentLocks :: AgentLocks} | CRAgentSubsTotal {user :: User, subsTotal :: SMPServerSubs, hasSession :: Bool} | CRAgentServersSummary {user :: User, serversSummary :: PresentedServersSummary} @@ -813,8 +831,6 @@ data ChatResponse | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} - | CRArchiveExported {archiveErrors :: [ArchiveError]} - | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} @@ -842,7 +858,9 @@ allowRemoteEvent = \case CRRemoteCtrlConnected _ -> False CRRemoteCtrlStopped {} -> False CRSQLResult _ -> False +#if !defined(dbPostgres) CRSlowSQLQueries {} -> False +#endif _ -> True logResponseToFile :: ChatResponse -> Bool @@ -1032,7 +1050,7 @@ data ContactSubStatus = ContactSubStatus deriving (Show) data MemberSubStatus = MemberSubStatus - { member :: GroupMember, + { member :: ShortGroupMember, memberError :: Maybe ChatError } deriving (Show) @@ -1171,11 +1189,13 @@ data CoreVersionInfo = CoreVersionInfo } deriving (Show) +#if !defined(dbPostgres) data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } deriving (Show) +#endif data ChatError = ChatError {errorType :: ChatErrorType} @@ -1502,13 +1522,17 @@ withStoreBatch actions = do ChatController {chatStore} <- ask liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions +-- TODO [postgres] postgres specific error handling handleDBErrors :: [E.Handler IO (Either ChatError a)] handleDBErrors = - [ E.Handler $ \(e :: SQLError) -> +#if !defined(dbPostgres) + ( E.Handler $ \(e :: SQLError) -> let se = SQL.sqlError e busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked - in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, - E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e + in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e + ) : +#endif + [ E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e ] withStoreBatch' :: Traversable t => (DB.Connection -> t (IO a)) -> CM' (t (Either ChatError a)) @@ -1581,7 +1605,9 @@ $(JQ.deriveJSON defaultJSON ''ChatItemDeletion) $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) +#if !defined(dbPostgres) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) +#endif -- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where -- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 37a5d5bf0d..0dbee1542e 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -26,21 +26,22 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.View (serializeChatResponse) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore, withTransaction) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent, yesToUpMigrations}} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}} chat = case logAgent of Just level -> do setLogLevel level withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbFilePrefix dbKey False confirm' >>= either exit run + initRun = createChatDatabase dbOptions confirm' >>= either exit run confirm' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations exit e = do putStrLn $ "Error opening database: " <> show e @@ -66,7 +67,7 @@ sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc -getSelectActiveUser :: SQLiteStore -> IO (Maybe User) +getSelectActiveUser :: DBStore -> IO (Maybe User) getSelectActiveUser st = do users <- withTransaction st getUsers case find activeUser users of diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index cadf35f580..bf568f7946 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -26,8 +27,6 @@ import Control.Monad.Reader import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Bifunctor (bimap, first, second) -import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -52,9 +51,7 @@ import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 -import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Library.Subscriber -import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -87,13 +84,12 @@ import Simplex.Chat.Util (liftIOEither) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (execSQL, upMigration, withConnection) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations +import Simplex.Messaging.Agent.Store.Interface (execSQL) +import Simplex.Messaging.Agent.Store.Shared (upMigration) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -111,7 +107,7 @@ import Simplex.Messaging.Version import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitSuccess) -import System.FilePath (takeFileName, ()) +import System.FilePath (takeExtension, takeFileName, ()) import System.IO (Handle, IOMode (..)) import System.Random (randomRIO) import UnliftIO.Async @@ -120,6 +116,18 @@ import UnliftIO.Directory import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose) import UnliftIO.STM +#if defined(dbPostgres) +import Data.Bifunctor (bimap, second) +import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +#else +import Data.Bifunctor (bimap, first, second) +import qualified Data.ByteArray as BA +import qualified Database.SQLite.Simple as SQL +import Simplex.Chat.Archive +import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Store.Common (withConnection) +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif _defaultNtfServers :: [NtfServer] _defaultNtfServers = @@ -137,6 +145,15 @@ imageExtensions = [".jpg", ".jpeg", ".png", ".gif"] fixedImagePreview :: ImageData fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==" +imageFilePrefix :: String +imageFilePrefix = "IMG_" + +voiceFilePrefix :: String +voiceFilePrefix = "voice_" + +videoFilePrefix :: String +videoFilePrefix = "video_" + -- enableSndFiles has no effect when mainApp is True startChatController :: Bool -> Bool -> CM' (Async ()) startChatController mainApp enableSndFiles = do @@ -159,7 +176,7 @@ startChatController mainApp enableSndFiles = do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users startCleanupManager - void $ forkIO $ startExpireCIs users + void $ forkIO $ mapM_ startExpireCIs users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -174,12 +191,15 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs users = - forM_ users $ \user -> do - ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) - forM_ ttl $ \_ -> do - startExpireCIThread user - setExpireCIFlag user True + startExpireCIs user = whenM shouldExpireChats $ do + startExpireCIThread user + setExpireCIFlag user True + where + shouldExpireChats = + fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do + ttl <- getChatItemTTL db user + ttlCount <- getChatTTLCount db user + pure $ ttl > 0 || ttlCount > 0 subscribeUsers :: Bool -> [User] -> CM' () subscribeUsers onlyNeeded users = do @@ -444,6 +464,7 @@ processChatCommand' vr = \case chatWriteVar sel $ Just f APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ +#if !defined(dbPostgres) APIExportArchive cfg -> checkChatStopped $ CRArchiveExported <$> lift (exportArchive cfg) ExportArchive -> do ts <- liftIO getCurrentTime @@ -453,13 +474,9 @@ processChatCommand' vr = \case fileErrs <- lift $ importArchive cfg setStoreChanged pure $ CRArchiveImported fileErrs - APISaveAppSettings as -> withFastStore' (`saveAppSettings` as) >> ok_ - APIGetAppSettings platformDefaults -> CRAppSettings <$> withFastStore' (`getAppSettings` platformDefaults) APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg TestStorageEncryption key -> sqlCipherTestKey key >> ok_ - ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) - ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) SlowSQLQueries -> do ChatController {chatStore, smpAgent} <- ask chatQueries <- slowQueries chatStore @@ -472,6 +489,11 @@ processChatCommand' vr = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) +#endif + ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) + ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) + APISaveAppSettings as -> withFastStore' (`saveAppSettings` as) >> ok_ + APIGetAppSettings platformDefaults -> CRAppSettings <$> withFastStore' (`getAppSettings` platformDefaults) APIGetChatTags userId -> withUserId' userId $ \user -> do tags <- withFastStore' (`getUserChatTags` user) pure $ CRChatTags user tags @@ -479,15 +501,17 @@ processChatCommand' vr = \case (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews - APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of + APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do + when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId contentFilter pagination search) pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo CTLocal -> do + when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" @@ -512,7 +536,7 @@ processChatCommand' vr = \case Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> case cType of + APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of CTDirect -> withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) @@ -542,9 +566,28 @@ processChatCommand' vr = \case APIReorderChatTags tagIds -> withUser $ \user -> do withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds ok user - APICreateChatItems folderId cms -> withUser $ \user -> + APICreateChatItems folderId cms -> withUser $ \user -> do + mapM_ assertAllowedContent' cms createNoteFolderContentItems user folderId (L.map (,Nothing) cms) - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of + APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> + withGroupLock "reportMessage" gId $ do + (gInfo, ms) <- + withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) + let ms' = filter compatibleModerator ms + mc = MCReport reportText reportReason + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" + sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + where + compatibleModerator GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do + gId <- withFastStore $ \db -> getGroupIdByName db user groupName + reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage + processChatCommand $ APIReportMessage gId reportedItemId reportReason "" + APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ @@ -612,6 +655,7 @@ processChatCommand' vr = \case (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of CIDMInternal -> deleteDirectCIs user ct items True False + CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -627,6 +671,7 @@ processChatCommand' vr = \case ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier @@ -657,7 +702,7 @@ processChatCommand' vr = \case (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin + assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events @@ -778,6 +823,7 @@ processChatCommand' vr = \case MCVideo {text} -> text /= "" MCVoice {text} -> text /= "" MCFile t -> t /= "" + MCReport {} -> True MCUnknown {} -> True APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do @@ -862,7 +908,8 @@ processChatCommand' vr = \case ifM (doesFileExist fsFromPath) ( do - fsNewPath <- liftIO $ filesFolder `uniqueCombine` fileName + newFileName <- liftIO $ maybe (pure fileName) (generateNewFileName fileName) $ mediaFilePrefix mc + fsNewPath <- liftIO $ filesFolder `uniqueCombine` newFileName liftIO $ B.writeFile fsNewPath "" -- create empty file encrypt <- chatReadVar encryptLocalFiles cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing @@ -899,6 +946,17 @@ processChatCommand' vr = \case when (B.length ch /= chSize') $ throwError $ CF.FTCEFileIOError "encrypting file: unexpected EOF" liftIO . CF.hPut w $ LB.fromStrict ch when (size' > 0) $ copyChunks r w size' + mediaFilePrefix :: MsgContent -> Maybe FilePath + mediaFilePrefix = \case + MCImage {} -> Just imageFilePrefix + MCVoice {} -> Just voiceFilePrefix + MCVideo {} -> Just videoFilePrefix + _ -> Nothing + generateNewFileName fileName prefix = do + currentDate <- liftIO getCurrentTime + let formattedDate = formatTime defaultTimeLocale "%Y%m%d_%H%M%S" currentDate + let ext = takeExtension fileName + pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId APIChatRead chatRef@(ChatRef cType chatId) -> withUser $ \_ -> case cType of @@ -1201,6 +1259,11 @@ processChatCommand' vr = \case ct <- getContact db vr user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' + APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do + gInfo' <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + liftIO $ updateGroupAlias db userId gInfo localAlias + pure $ CRGroupAliasUpdated user gInfo' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do conn' <- withFastStore $ \db -> do conn <- getPendingContactConnection db userId connId @@ -1346,27 +1409,55 @@ processChatCommand' vr = \case currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs CRServerOperatorConditions <$> getServerOperators db - APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> + APISetChatTTL userId (ChatRef cType chatId) newTTL_ -> + withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do + (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> + (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + let newTTL = fromMaybe globalTTL newTTL_ + oldTTL = fromMaybe globalTTL oldTTL_ + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user)) + lift $ setChatItemsExpiration user globalTTL ttlCount + ok user + where + getSetChatTTL db = case cType of + CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ + CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + _ -> pure Nothing + expireChat user globalTTL = do + currentTs <- liftIO getCurrentTime + case cType of + CTDirect -> expireContactChatItems user vr globalTTL chatId + CTGroup -> + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + in expireGroupChatItems user vr globalTTL createdAtCutoff chatId + _ -> throwChatError $ CECommandError "not supported" + SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do + chatRef <- getChatRef user chatName + processChatCommand $ APISetChatTTL userId chatRef newTTL + GetChatTTL chatName -> withUser' $ \user -> do + ChatRef cType chatId <- getChatRef user chatName + ttl <- case cType of + CTDirect -> withFastStore' (`getDirectChatTTL` chatId) + CTGroup -> withFastStore' (`getGroupChatTTL` chatId) + _ -> throwChatError $ CECommandError "not supported" + pure $ CRChatItemTTL user ttl + APISetChatItemTTL userId newTTL -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do - case newTTL_ of - Nothing -> do - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ setExpireCIFlag user False - Just newTTL -> do - oldTTL <- withFastStore' (`getChatItemTTL` user) - when (maybe True (newTTL <) oldTTL) $ do - lift $ setExpireCIFlag user False - expireChatItems user newTTL True - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ startExpireCIThread user - lift . whenM chatStarted $ setExpireCIFlag user True + (oldTTL, ttlCount) <- withFastStore' $ \db -> + (,) <$> getChatItemTTL db user <* setChatItemTTL db user newTTL <*> getChatTTLCount db user + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChatItems user newTTL True + lift $ setChatItemsExpiration user newTTL ttlCount ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withFastStore' (`getChatItemTTL` user) - pure $ CRChatItemTTL user ttl + pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ @@ -1535,6 +1626,7 @@ processChatCommand' vr = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId m <- withFastStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo + -- TODO GRModerator when most users migrate when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} processChatCommand $ APISetMemberSettings gId mId settings @@ -1897,12 +1989,12 @@ processChatCommand' vr = \case pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName - APIJoinGroup groupId -> withUser $ \user@User {userId} -> do + APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId . procCmd $ do (invitation, ct) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId (inv,) <$> getContactViaMember db vr user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of @@ -1919,7 +2011,9 @@ processChatCommand' vr = \case withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - void (withAgent $ \a -> joinConnection a (aUserId user) agentConnId True connRequest dm PQSupportOff subMode) + -- MFAll is default for new groups + unless (enableNtfs == MFAll) $ updateGroupSettings db user groupId chatSettings {enableNtfs} + void (withAgent $ \a -> joinConnection a (aUserId user) agentConnId (enableNtfs /= MFNone) connRequest dm PQSupportOff subMode) `catchChatError` \e -> do withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemInvited @@ -1959,6 +2053,7 @@ processChatCommand' vr = \case Nothing -> throwChatError $ CEException "expected to find a single blocked member" Just (bm, remainingMembers) -> do let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm + -- TODO GRModerator when most users migrate assertUserGroupRole gInfo $ max GRAdmin bmRole when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" withGroupLock "blockForAll" groupId . procCmd $ do @@ -2015,9 +2110,9 @@ processChatCommand' vr = \case AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole - JoinGroup gName -> withUser $ \user -> do + JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIJoinGroup groupId + processChatCommand $ APIJoinGroup groupId enableNtfs MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked RemoveMember gName gMemberName -> withMemberName gName gMemberName APIRemoveMember @@ -2132,14 +2227,14 @@ processChatCommand' vr = \case pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search + chatResp <- processChatCommand $ APIGetChat chatRef Nothing (CPLast count) search pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast count) search pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) + chatResp <- processChatCommand (APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing) pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) LastChatItemId Nothing index -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast $ index + 1) Nothing @@ -2311,7 +2406,7 @@ processChatCommand' vr = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' (Migrations.getCurrent . DB.conn) + chatMigrations <- map upMigration <$> withFastStore' Migrations.getCurrent agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do @@ -2391,12 +2486,14 @@ processChatCommand' vr = \case | name == "" -> withFastStore (`getUserNoteFolderId` user) | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" +#if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) setStoreChanged :: CM () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) withStoreChanged :: CM () -> CM ChatResponse withStoreChanged a = checkChatStopped $ a >> setStoreChanged >> ok_ +#endif checkStoreNotChanged :: CM ChatResponse -> CM ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) withUserName :: UserName -> (UserId -> ChatCommand) -> CM ChatResponse @@ -2613,6 +2710,9 @@ processChatCommand' vr = \case delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> CM ChatResponse delGroupChatItems user gInfo items byGroupMember = do deletedTs <- liftIO getCurrentTime + forM_ byGroupMember $ \byMember -> do + ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci byMember deletedTs) + unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCIs user gInfo items True False byGroupMember deletedTs else markGroupCIsDeleted user gInfo items True byGroupMember deletedTs @@ -2845,6 +2945,12 @@ processChatCommand' vr = \case forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) _ -> pure () -- prohibited + assertAllowedContent :: MsgContent -> CM () + assertAllowedContent = \case + MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" + _ -> pure () + assertAllowedContent' :: ComposedMessage -> CM () + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs @@ -2905,13 +3011,16 @@ processChatCommand' vr = \case sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs - g@(Group gInfo _) <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + sendGroupContentMessages_ user gInfo ms live itemTTL cmrs + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do assertUserGroupRole gInfo GRAuthor - assertGroupContentAllowed gInfo - processComposedMessages g + assertGroupContentAllowed + processComposedMessages where - assertGroupContentAllowed :: GroupInfo -> CM () - assertGroupContentAllowed gInfo@GroupInfo {membership} = + assertGroupContentAllowed :: CM () + assertGroupContentAllowed = case findProhibited (L.toList cmrs) of Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () @@ -2921,8 +3030,8 @@ processChatCommand' vr = \case foldr' (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) Nothing - processComposedMessages :: Group -> CM ChatResponse - processComposedMessages g@(Group gInfo ms) = do + processComposedMessages :: CM ChatResponse + processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -2943,7 +3052,7 @@ processChatCommand' vr = \case forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup g + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) @@ -3001,7 +3110,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3173,9 +3282,16 @@ startExpireCIThread user@User {userId} = do atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False + expireChatItems user ttl False liftIO $ threadDelay' interval +setChatItemsExpiration :: User -> Int64 -> Int -> CM' () +setChatItemsExpiration user newTTL ttlCount + | newTTL > 0 || ttlCount > 0 = do + startExpireCIThread user + whenM chatStarted $ setExpireCIFlag user True + | otherwise = setExpireCIFlag user False + setExpireCIFlag :: User -> Bool -> CM' () setExpireCIFlag User {userId} b = do expireFlags <- asks expireCIFlags @@ -3231,17 +3347,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce - -- TODO possibly, we could either disable these events or replace with less noisy for API - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs + unlessM (asks $ coreApi . config) $ do + contactLinkSubsToView rs ucs + groupSubsToView rs gs ms ce + sndFileSubsToView rs sfts + rcvFileSubsToView rs rfts + pendingConnSubsToView rs pcs where addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) @@ -3261,6 +3377,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do createdAt, updatedAt = createdAt } + toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = + ShortGroupMember + { groupMemberId, + groupId, + memberName = localDisplayName, + connId = agentConnId + } getContactConns :: CM ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ (`getUserContacts` vr) @@ -3271,11 +3394,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember) + getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) getGroupMemberConns = do - gs <- withStore_ (`getUserGroups` vr) - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs + gs <- withStore_ getUserGroupsToSubscribe + let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs pure (gs, map fst mPairs, M.fromList mPairs) + where + shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do sfts <- withStore_ getLiveSndFileTransfers @@ -3319,30 +3444,27 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM () + groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs + sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms - groupSub :: Group -> CM () - groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do + groupSub :: ShortGroup -> CM () + groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where - mErrors :: [(GroupMember, ChatError)] + mErrors :: [(ShortGroupMember, ChatError)] mErrors = - sortOn (\(GroupMember {localDisplayName = n}, _) -> n) + sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors - $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs + $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation user g - | all (\GroupMember {activeConn} -> isNothing activeConn) members = - if memberActive membership - then CRGroupEmpty user g - else CRGroupRemoved user g + | membershipStatus == GSMemInvited = CRGroupInvitation user g + | null members = CRGroupEmpty user g | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do @@ -3423,20 +3545,19 @@ cleanupManager = do withStore' (`deleteOldProbes` cutoffTs) expireChatItems :: User -> Int64 -> Bool -> CM () -expireChatItems user@User {userId} ttl sync = do +expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime vr <- chatVersionRange - let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs - -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts - createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs lift waitChatStartedAndActivated - contacts <- withStore' $ \db -> getUserContacts db vr user - loop contacts $ processContact expirationDate + contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL + loop contactIds $ expireContactChatItems user vr globalTTL lift waitChatStartedAndActivated - groups <- withStore' $ \db -> getUserGroupDetails db vr user Nothing Nothing - loop groups $ processGroup vr expirationDate createdAtCutoff + groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL + loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff where - loop :: [a] -> (a -> CM ()) -> CM () + loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do process a `catchChatError` (toView . CRChatError (Just user)) @@ -3449,22 +3570,40 @@ expireChatItems user@User {userId} ttl sync = do expireFlags <- asks expireCIFlags expire <- atomically $ TM.lookup userId expireFlags when (expire == Just True) $ threadDelay 100000 >> a - processContact :: UTCTime -> Contact -> CM () - processContact expirationDate ct = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate - processGroup :: VersionRangeChat -> UTCTime -> UTCTime -> GroupInfo -> CM () - processGroup vr expirationDate createdAtCutoff gInfo = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo - forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM () +expireContactChatItems user vr globalTTL ctId = + -- reading contacts and groups inside the loop, + -- to allow ttl changing while processing and to reduce memory usage + tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + where + process ct@Contact {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate + +expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () +expireGroupChatItems user vr globalTTL createdAtCutoff groupId = + tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process + where + process gInfo@GroupInfo {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM () +withExpirationDate globalTTL chatItemTTL action = do + currentTs <- liftIO getCurrentTime + let ttl = fromMaybe globalTTL chatItemTTL + when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs chatCommandP :: Parser ChatCommand chatCommandP = @@ -3473,13 +3612,13 @@ chatCommandP = "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), - "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), + "/block #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), - ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), + ("/user " <|> "/u ") *> (SetActiveUser <$> displayNameP <*> optional (A.space *> pwdP)), "/set receipts all " *> (SetAllContactReceipts <$> onOffP), "/_set receipts contacts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings), "/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings), @@ -3494,7 +3633,7 @@ chatCommandP = "/mute user" $> MuteUser, "/unmute user" $> UnmuteUser, "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), - "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), + "/delete user " *> (DeleteUser <$> displayNameP <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, "/_start " *> do mainApp <- "main=" *> onOffP @@ -3516,6 +3655,7 @@ chatCommandP = "/set file paths " *> (APISetAppFilePaths <$> jsonP), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), +#if !defined(dbPostgres) "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), @@ -3525,11 +3665,12 @@ chatCommandP = "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/db test key " *> (TestStorageEncryption <$> dbKeyP), + "/sql slow" $> SlowSQLQueries, +#endif "/_save app settings" *> (APISaveAppSettings <$> jsonP), "/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), - "/sql slow" $> SlowSQLQueries, "/_get tags " *> (APIGetChatTags <$> A.decimal), "/_get chats " *> ( APIGetChats @@ -3538,7 +3679,7 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), @@ -3548,8 +3689,10 @@ chatCommandP = "/_update tag " *> (APIUpdateChatTag <$> A.decimal <* A.space <*> jsonP), "/_reorder tags " *> (APIReorderChatTags <$> strP), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), + "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), - "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), + "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), @@ -3565,7 +3708,7 @@ chatCommandP = "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), - "/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType), + "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP), "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP), @@ -3576,6 +3719,7 @@ chatCommandP = "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), + "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), @@ -3588,7 +3732,7 @@ chatCommandP = "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), - "/_join #" *> (APIJoinGroup <$> A.decimal), + "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP), "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), @@ -3611,10 +3755,13 @@ chatCommandP = "/_conditions" $> APIGetUsageConditions, "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), - "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), - "/ttl " *> (SetChatItemTTL <$> ciTTL), + "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> A.decimal), + "/_ttl " *> (APISetChatTTL <$> A.decimal <* A.space <*> chatRefP <* A.space <*> ciTTLDecimal), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), + "/ttl " *> (SetChatItemTTL <$> ciTTL), "/ttl" $> GetChatItemTTL, + "/ttl " *> (SetChatTTL <$> chatNameP <* A.space <*> (("default" $> Nothing) <|> (Just <$> ciTTL))), + "/ttl " *> (GetChatTTL <$> chatNameP), "/_network info " *> (APISetNetworkInfo <$> jsonP), "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (SetNetworkConfig <$> netCfgP), @@ -3626,37 +3773,37 @@ chatCommandP = "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), - ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayName), - ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName), + ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayNameP), + ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayNameP), "/_queue info #" *> (APIGroupMemberQueueInfo <$> A.decimal <* A.space <*> A.decimal), "/_queue info @" *> (APIContactQueueInfo <$> A.decimal), - ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayName), + ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayNameP), "/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_switch @" *> (APISwitchContact <$> A.decimal), "/_abort switch #" *> (APIAbortSwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_abort switch @" *> (APIAbortSwitchContact <$> A.decimal), "/_sync #" *> (APISyncGroupMemberRatchet <$> A.decimal <* A.space <*> A.decimal <*> (" force=on" $> True <|> pure False)), "/_sync @" *> (APISyncContactRatchet <$> A.decimal <*> (" force=on" $> True <|> pure False)), - "/switch #" *> (SwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/switch " *> char_ '@' *> (SwitchContact <$> displayName), - "/abort switch #" *> (AbortSwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayName), - "/sync #" *> (SyncGroupMemberRatchet <$> displayName <* A.space <* char_ '@' <*> displayName <*> (" force=on" $> True <|> pure False)), - "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), + "/switch #" *> (SwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/switch " *> char_ '@' *> (SwitchContact <$> displayNameP), + "/abort switch #" *> (AbortSwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayNameP), + "/sync #" *> (SyncGroupMemberRatchet <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (" force=on" $> True <|> pure False)), + "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayNameP <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), - "/code " *> char_ '@' *> (GetContactCode <$> displayName), - "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), - "/enable " *> char_ '@' *> (EnableContact <$> displayName), - "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), + "/code " *> char_ '@' *> (GetContactCode <$> displayNameP), + "/code #" *> (GetGroupMemberCode <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/verify " *> char_ '@' *> (VerifyContact <$> displayNameP <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> optional (A.space *> verifyCodeP)), + "/enable " *> char_ '@' *> (EnableContact <$> displayNameP), + "/enable #" *> (EnableGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, @@ -3669,40 +3816,40 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), - ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), - ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), - ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), - "/block for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), - "/unblock for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayName), - ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName), - ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName <*> chatDeleteMode), + ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), + ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), + ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), + "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), + "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), + ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), + ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), "/clear *" $> ClearNoteFolder, - "/clear #" *> (ClearGroup <$> displayName), - "/clear " *> char_ '@' *> (ClearContact <$> displayName), - ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName), + "/clear #" *> (ClearGroup <$> displayNameP), + "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), + ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayName) <*> optional (A.space *> stringP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), - "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), - "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <* A.space <*> (Just <$> msgTextP)), - "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> pure Nothing), - "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayName), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), + "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), + "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), + "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayName <*> (memberRole <|> pure GRMember)), - "/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole), - "/delete link #" *> (DeleteGroupLink <$> displayName), - "/show link #" *> (ShowGroupLink <$> displayName), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember)), + "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), + "/delete link #" *> (DeleteGroupLink <$> displayNameP), + "/show link #" *> (ShowGroupLink <$> displayNameP), "/_create member contact #" *> (APICreateMemberContact <$> A.decimal <* A.space <*> A.decimal), "/_invite member contact @" *> (APISendMemberContactInvitation <$> A.decimal <*> optional (A.space *> msgContentP)), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <* char_ '@' <*> (Just <$> displayNameP) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), @@ -3712,18 +3859,18 @@ chatCommandP = "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), - ForwardMessage <$> chatNameP <* " <- @" <*> displayName <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <* A.space <* A.char '@' <*> (Just <$> displayName) <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <*> pure Nothing <* A.space <*> msgTextP, + ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, SendMessage <$> chatNameP <* A.space <*> msgTextP, "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), - "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), + "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd), ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP), - ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> textP), + ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), ReactToMessage <$> (("+" $> True) <|> ("-" $> False)) <*> reactionP <* A.space <*> chatNameP' <* A.space <*> textP, "/feed " *> (SendMessageBroadcast <$> msgTextP), @@ -3755,8 +3902,8 @@ chatCommandP = ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), - ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName), - ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), + ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayNameP), + ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayNameP), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, ("/welcome" <|> "/w") $> Welcome, "/set profile image " *> (UpdateProfileImage . Just . ImageData <$> imageP), @@ -3764,22 +3911,22 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNames), ("/profile" <|> "/p") $> ShowProfile, - "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayName <*> _strP <*> optional memberRole), - "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), + "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), + "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayNameP <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), - "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayName <*> _strP <*> optional memberRole), - "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayName <*> (A.space *> strP)), - "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayName <*> (A.space *> strP)), - "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), + "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayNameP <*> _strP <*> optional memberRole), + "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayNameP <*> (A.space *> strP)), + "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), + "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayName <*> (A.space *> strP)), - "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayName <*> optional (A.space *> strP)), + "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), - "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayName <*> _strP <*> optional memberRole), - "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), - "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), + "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), + "/set disappear #" *> (SetGroupTimedMessages <$> displayNameP <*> (A.space *> timedTTLOnOffP)), + "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), - "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayName <*> _strP <*> optional memberRole), + "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -3832,7 +3979,6 @@ chatCommandP = <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP - ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal chatDeleteMode = A.choice [ " full" *> (CDMFull <$> notifyP), @@ -3842,14 +3988,7 @@ chatCommandP = ] where notifyP = " notify=" *> onOffP <|> pure True - displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) - where - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted cs = A.choice [A.char c *> takeNameTill (== c) <* A.char c | c <- cs] - refChar c = c > ' ' && c /= '#' && c /= '@' - sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP + sendMsgQuote msgDir = SendMessageQuote <$> displayNameP <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) toEmoji = \case @@ -3871,7 +4010,7 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) - profileNames = (,) <$> displayName <*> fullNameP + profileNames = (,) <$> displayNameP <*> fullNameP newUserP = do (cName, fullName) <- profileNames let profile = Just Profile {displayName = cName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} @@ -3902,22 +4041,24 @@ chatCommandP = A.choice [ " owner" $> GROwner, " admin" $> GRAdmin, + " moderator" $> GRModerator, " member" $> GRMember, " observer" $> GRObserver ] chatNameP = chatTypeP >>= \case CTLocal -> pure $ ChatName CTLocal "" - ct -> ChatName ct <$> displayName - chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName + ct -> ChatName ct <$> displayNameP + chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 - ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) + ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = - ("day" $> Just 86400) - <|> ("week" $> Just (7 * 86400)) - <|> ("month" $> Just (30 * 86400)) - <|> ("none" $> Nothing) + ("day" $> 86400) + <|> ("week" $> (7 * 86400)) + <|> ("month" $> (30 * 86400)) + <|> ("year" $> (365 * 86400)) + <|> ("none" $> 0) timedTTLP = ("30s" $> 30) <|> ("5min" $> 300) @@ -3960,9 +4101,11 @@ chatCommandP = logTLSErrors <- " log=" *> onOffP <|> pure False let tcpTimeout_ = (1000000 *) <$> t_ pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} +#if !defined(dbPostgres) dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} +#endif autoAcceptP = ifM onOffP (Just <$> (businessAA <|> addressAA)) (pure Nothing) where addressAA = AutoAccept False <$> (" incognito=" *> onOffP <|> pure False) <*> autoReply @@ -3972,6 +4115,15 @@ chatCommandP = text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char +displayNameP :: Parser Text +displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')) + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' + mkValidName :: String -> String mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index cfb15daca4..1bcbf9f20d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -79,7 +79,7 @@ import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkConfig (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -220,6 +220,7 @@ quoteContent mc qmc ciFile_ MCImage {} -> True MCVideo {} -> True MCVoice {} -> False + MCReport {} -> False MCUnknown {} -> True qText = msgContentText qmc getFileName :: CIFile d -> String diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2df77a074e..4c95375f73 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -43,12 +43,12 @@ import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Library.Internal import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol -import Simplex.Chat.Library.Internal import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct @@ -70,7 +70,7 @@ import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProxyClientError (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -284,6 +284,7 @@ processAgentMsgSndFile _corrId aFileId msg = do agentFileError :: AgentErrorType -> FileError agentFileError = \case XFTP _ XFTP.AUTH -> FileErrAuth + XFTP srv (XFTP.BLOCKED info) -> FileErrBlocked srv info FILE NO_FILE -> FileErrNoFile BROKER _ e -> brokerError FileErrRelay e e -> FileErrOther $ tshow e @@ -1749,7 +1750,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci groupMsgToView gInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} - | moderatorRole < GRAdmin || moderatorRole < memberRole = + | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False @@ -1834,23 +1835,29 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CIGroupSnd -> moderate membership cci Left e | msgMemberId == memberId -> messageError $ "x.msg.del: message not found, " <> tshow e - | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + | senderRole < GRModerator -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where moderate :: GroupMember -> CChatItem 'CTGroup -> CM () moderate mem cci = case sndMemberId_ of Just sndMemberId - | sameMemberId sndMemberId mem -> checkRole mem $ delete cci (Just m) >>= toView + | sameMemberId sndMemberId mem -> checkRole mem $ do + delete cci (Just m) >>= toView + archiveMessageReports cci m | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" checkRole GroupMember {memberRole} a - | senderRole < GRAdmin || senderRole < memberRole = + | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse delete cci byGroupMember | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs + archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () + archiveMessageReports (CChatItem _ ci) byMember = do + ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs + unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () @@ -2580,7 +2587,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} - | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" + | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index a477deeb2c..55542b1d2f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -38,12 +38,11 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, nominalDay) import Data.Type.Equality import Data.Typeable (Typeable) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import GHC.TypeLits (ErrorMessage (ShowType, type (:<>:)), TypeError) import qualified GHC.TypeLits as Type import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -53,7 +52,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -91,14 +90,6 @@ chatInfoChatTs = \case GroupChat GroupInfo {chatTs} -> chatTs _ -> Nothing -chatInfoUpdatedAt :: ChatInfo c -> UTCTime -chatInfoUpdatedAt = \case - DirectChat Contact {updatedAt} -> updatedAt - GroupChat GroupInfo {updatedAt} -> updatedAt - LocalChat NoteFolder {updatedAt} -> updatedAt - ContactRequest UserContactRequest {updatedAt} -> updatedAt - ContactConnection PendingContactConnection {updatedAt} -> updatedAt - chatInfoToRef :: ChatInfo c -> ChatRef chatInfoToRef = \case DirectChat Contact {contactId} -> ChatRef CTDirect contactId @@ -318,12 +309,16 @@ data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat data ChatStats = ChatStats - { unreadCount :: Int, + { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) +emptyChatStats :: ChatStats +emptyChatStats = ChatStats 0 0 0 False + data NavigationInfo = NavigationInfo { afterUnread :: Int, afterTotal :: Int @@ -739,6 +734,7 @@ aciFileStatusJSON = \case data FileError = FileErrAuth + | FileErrBlocked {server :: String, blockInfo :: BlockingInfo} | FileErrNoFile | FileErrRelay {srvError :: SrvError} | FileErrOther {fileError :: Text} @@ -747,14 +743,16 @@ data FileError instance StrEncoding FileError where strEncode = \case FileErrAuth -> "auth" + FileErrBlocked srv info -> "blocked " <> strEncode (srv, info) FileErrNoFile -> "no_file" FileErrRelay srvErr -> "relay " <> strEncode srvErr FileErrOther e -> "other " <> encodeUtf8 e strP = A.takeWhile1 (/= ' ') >>= \case "auth" -> pure FileErrAuth + "blocked" -> FileErrBlocked <$> _strP <*> _strP "no_file" -> pure FileErrNoFile - "relay" -> FileErrRelay <$> (A.space *> strP) + "relay" -> FileErrRelay <$> _strP "other" -> FileErrOther . safeDecodeUtf8 <$> (A.space *> A.takeByteString) s -> FileErrOther . safeDecodeUtf8 . (s <>) <$> A.takeByteString diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index e198183b06..60d5464b79 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -17,14 +17,14 @@ module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality import Data.Word (Word32) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Messages.CIContent.Events +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -103,15 +103,33 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) -$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) +instance StrEncoding CIDeleteMode where + strEncode = \case + CIDMBroadcast -> "broadcast" + CIDMInternal -> "internal" + CIDMInternalMark -> "internalMark" + strP = + A.takeTill (== ' ') >>= \case + "broadcast" -> pure CIDMBroadcast + "internal" -> pure CIDMInternal + "internalMark" -> pure CIDMInternalMark + _ -> fail "bad CIDeleteMode" + +instance ToJSON CIDeleteMode where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON CIDeleteMode where + parseJSON = strParseJSON "CIDeleteMode" ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" - CIDMInternal -> "this item is deleted (internal)" + CIDMInternal -> "this item is deleted (locally)" + CIDMInternalMark -> "this item is deleted (locally)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 5af24434e9..cda7d32ae1 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -16,7 +17,6 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import Data.ByteArray (ScrubbedBytes) -import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -27,8 +27,6 @@ import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Word (Word8) -import Database.SQLite.Simple (SQLError (..)) -import qualified Database.SQLite.Simple as DB import Foreign.C.String import Foreign.C.Types (CBool (..), CInt (..), CLong (..)) import Foreign.Ptr @@ -44,6 +42,7 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -51,7 +50,8 @@ import Simplex.Chat.Types import Simplex.Chat.Util (liftIOEither) import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore, reopenSQLiteStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) @@ -59,6 +59,10 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +#if !defined(dbPostgres) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB +#endif data DBMigrationResult = DBMOk @@ -117,9 +121,11 @@ foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString foreign export ccall "chat_resize_image_to_str_size" cChatResizeImageToStrSize :: CString -> CLong -> IO CString -- | check / migrate database and initialize chat controller on success +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInitKey fp key keepKey conf background ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to @@ -128,11 +134,10 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do setFileSystemEncoding utf8 setForeignEncoding utf8 - dbPath <- peekCString fp - dbKey <- BA.convert <$> B.packCString key + chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -221,13 +226,12 @@ cChatResizeImageToStrSize fp' maxSize = do -- cChatStripImageMetadata :: CString -> IO CBool -- cChatStripImageMetadata path = error "todo" -mobileChatOpts :: String -> ChatOpts -mobileChatOpts dbFilePrefix = +mobileChatOpts :: ChatDbOpts -> ChatOpts +mobileChatOpts dbOptions = ChatOpts { coreOptions = CoreChatOpts - { dbFilePrefix, - dbKey = "", -- for API database is already opened, and the key in options is not used + { dbOptions, smpServers = [], xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, @@ -236,7 +240,7 @@ mobileChatOpts dbFilePrefix = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 1024, + tbqSize = 4096, highlyAvailable = False, yesToUpMigrations = False }, @@ -264,42 +268,53 @@ defaultMobileConfig = deviceNameForRemote = "Mobile" } -getActiveUser_ :: SQLiteStore -> IO (Maybe User) +getActiveUser_ :: DBStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers +#if !defined(dbPostgres) +-- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInit dbFilePrefix dbKey confirm = chatMigrateInitKey dbFilePrefix dbKey False confirm False +chatMigrateInit dbFilePrefix dbKey confirm = do + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + chatMigrateInitKey chatDBOpts False confirm False +#endif -chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm - chatStore <- migrate createChatStore (chatStoreFile dbFilePrefix) confirmMigrations - agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) confirmMigrations + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) confirmMigrations liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where + opts = mobileChatOpts $ removeDbKey chatDbOpts initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) backgroundMode - migrate createStore dbFile confirmMigrations = + newChatController db user_ defaultMobileConfig opts backgroundMode + migrate createStore dbOpts confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations) + (first (DBMErrorMigration errDbStr) <$> createStore dbOpts confirmMigrations) +#if !defined(dbPostgres) `catch` (pure . checkDBError) +#endif `catchAll` (pure . dbError) where + errDbStr = errorDbStr dbOpts +#if !defined(dbPostgres) checkDBError e = case sqlError e of - DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile + DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase errDbStr _ -> dbError e - dbError e = Left . DBMErrorSQL dbFile $ show e +#endif + dbError e = Left . DBMErrorSQL errDbStr $ show e chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeSQLiteStore chatStore - closeSQLiteStore $ agentClientStore smpAgent + closeDBStore chatStore + closeDBStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do - reopenSQLiteStore chatStore - reopenSQLiteStore (agentClientStore smpAgent) + reopenDBStore chatStore + reopenDBStore (agentClientStore smpAgent) handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index e14e95211a..6af0c4a17e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -43,10 +44,9 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -119,7 +119,14 @@ instance TextEncoding OperatorTag where -- this and other types only define instances of serialization for known DB IDs only, -- entities without IDs cannot be serialized to JSON -instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f +instance FromField DBEntityId +#if defined(dbPostgres) + where + fromField f dat = DBEntityId <$> fromField f dat +#else + where + fromField f = DBEntityId <$> fromField f +#endif instance ToField DBEntityId where toField (DBEntityId i) = toField i @@ -167,7 +174,7 @@ conditionsRequiredOrDeadline createdAt notifiedAtOrNow = conditionsDeadline = addUTCTime (31 * nominalDay) data ConditionsAcceptance - = CAAccepted {acceptedAt :: Maybe UTCTime} + = CAAccepted {acceptedAt :: Maybe UTCTime, autoAccepted :: Bool} | CARequired {deadline :: Maybe UTCTime} deriving (Show) @@ -300,22 +307,22 @@ newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} -- This function should be used inside DB transaction to update conditions in the database --- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) -usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +-- it evaluates to (current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit -- This function is used in unit tests -usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case [] - | newUser -> (Just sourceCond, sourceCond, [sourceCond]) - | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + | newUser -> (sourceCond, [sourceCond]) + | otherwise -> (sourceCond, [prevCond, sourceCond]) where prevCond = conditions 1 prevCommit sourceCond = conditions 2 sourceCommit conds - | hasSourceCond -> (Nothing, last conds, []) - | otherwise -> (Nothing, sourceCond, [sourceCond]) + | hasSourceCond -> (last conds, []) + | otherwise -> (sourceCond, [sourceCond]) where hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds sourceCond = conditions cId sourceCommit @@ -338,7 +345,7 @@ updatedServerOperators presetOps storedOps = <> map (\op -> (Nothing, Just $ ASO SDBStored op)) (filter (isNothing . operatorTag) storedOps) where -- TODO remove domains of preset operators from custom - addPreset op = ((Just op, storedOp' <$> pOperator op) :) + addPreset op = ((Just op, storedOp' <$> pOperator op) :) where storedOp' presetOp = case find ((operatorTag presetOp ==) . operatorTag) storedOps of Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> @@ -427,7 +434,7 @@ groupByOperator_ (ops, smpSrvs, xftpSrvs) = do where mkUS op = UserOperatorServers op [] [] addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () - addServer ss custom add srv = + addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} @@ -445,7 +452,7 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others where currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss - noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] + noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] noServersErrs p user uss | noServers opEnabled = [USENoServers p' user] | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index f398831194..c58c792819 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -14,12 +14,12 @@ module Simplex.Chat.Options getChatOpts, protocolServersP, defaultHostMode, + printDbOpts, ) where import Control.Logger.Simple (LogLevel (..)) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -34,7 +34,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth (..), SocksAuth (..), defaultSocksProxyWithAuth) -import System.FilePath (combine) +import Simplex.Chat.Options.DB data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, @@ -54,8 +54,7 @@ data ChatOpts = ChatOpts } data CoreChatOpts = CoreChatOpts - { dbFilePrefix :: String, - dbKey :: ScrubbedBytes, + { dbOptions :: ChatDbOpts, smpServers :: [SMPServerWithAuth], xftpServers :: [XFTPServerWithAuth], simpleNetCfg :: SimpleNetCfg, @@ -81,24 +80,8 @@ agentLogLevel = \case CLLImportant -> LogInfo coreChatOptsP :: FilePath -> FilePath -> Parser CoreChatOpts -coreChatOptsP appDir defaultDbFileName = do - dbFilePrefix <- - strOption - ( long "database" - <> short 'd' - <> metavar "DB_FILE" - <> help "Path prefix to chat and agent database files" - <> value defaultDbFilePath - <> showDefault - ) - dbKey <- - strOption - ( long "key" - <> short 'k' - <> metavar "KEY" - <> help "Database encryption key/pass-phrase" - <> value "" - ) +coreChatOptsP appDir defaultDbName = do + dbOptions <- chatDbOptsP appDir defaultDbName smpServers <- option parseProtocolServers @@ -242,8 +225,7 @@ coreChatOptsP appDir defaultDbFileName = do ) pure CoreChatOpts - { dbFilePrefix, - dbKey, + { dbOptions, smpServers, xftpServers, simpleNetCfg = @@ -269,7 +251,6 @@ coreChatOptsP appDir defaultDbFileName = do } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p - defaultDbFilePath = combine appDir defaultDbFileName defaultHostMode :: Maybe SocksProxyWithAuth -> HostMode defaultHostMode = \case @@ -277,8 +258,8 @@ defaultHostMode = \case _ -> HMPublic chatOptsP :: FilePath -> FilePath -> Parser ChatOpts -chatOptsP appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +chatOptsP appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName deviceName <- optional $ strOption @@ -425,12 +406,15 @@ parseChatCmdLog = eitherReader $ \case _ -> Left "Invalid chat command log level" getChatOpts :: FilePath -> FilePath -> IO ChatOpts -getChatOpts appDir defaultDbFileName = +getChatOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> chatOptsP appDir defaultDbFileName) + (helper <*> versionOption <*> chatOptsP appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start chat with DB_FILE file and use SERVER as SMP server") where versionStr = versionString versionNumber versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") versionAndUpdate = versionStr <> "\n" <> updateStr + +printDbOpts :: CoreChatOpts -> IO () +printDbOpts opts = putStrLn $ "db: " <> dbString (dbOptions opts) diff --git a/src/Simplex/Chat/Options/DB.hs b/src/Simplex/Chat/Options/DB.hs new file mode 100644 index 0000000000..7e20e93e88 --- /dev/null +++ b/src/Simplex/Chat/Options/DB.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE CPP #-} + +module Simplex.Chat.Options.DB + +#if defined(dbPostgres) + ( module Simplex.Chat.Options.Postgres, + FromField (..), + ToField (..), + ) + where +import Simplex.Chat.Options.Postgres +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) + +#else + ( module Simplex.Chat.Options.SQLite, + FromField (..), + ToField (..), + ) + where +import Simplex.Chat.Options.SQLite +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) + +#endif diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs new file mode 100644 index 0000000000..b174ecd02e --- /dev/null +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -0,0 +1,68 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Options.Postgres where + +import qualified Data.ByteString.Char8 as B +import Foreign.C.String +import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) + +data ChatDbOpts = ChatDbOpts + { dbConnstr :: String, + dbSchemaPrefix :: String + } + +chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts +chatDbOptsP _appDir defaultDbName = do + dbConnstr <- + strOption + ( long "database" + <> short 'd' + <> metavar "DB_CONN" + <> help "Database connection string" + <> value ("postgresql://simplex@/" <> defaultDbName) + <> showDefault + ) + dbSchemaPrefix <- + strOption + ( long "schema-prefix" + <> metavar "DB_SCHEMA_PREFIX" + <> help "Database schema prefix" + <> value "simplex_v1" + <> showDefault + ) + pure ChatDbOpts {dbConnstr, dbSchemaPrefix} + +dbString :: ChatDbOpts -> String +dbString ChatDbOpts {dbConnstr} = dbConnstr + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix} dbSuffix _keepKey = + DBOpts + { connstr = B.pack dbConnstr, + schema = if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix + } + +chatSuffix :: String +chatSuffix = "_chat_schema" + +agentSuffix :: String +agentSuffix = "_agent_schema" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts schemaPrefix connstr = do + dbSchemaPrefix <- peekCString schemaPrefix + dbConnstr <- peekCString connstr + pure $ + ChatDbOpts + { dbConnstr, + dbSchemaPrefix + } + +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey = id + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {schema} = schema diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs new file mode 100644 index 0000000000..11eaf7e58c --- /dev/null +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Options.SQLite where + +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA +import qualified Data.ByteString.Char8 as B +import Foreign.C.String +import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import System.FilePath (combine) + +data ChatDbOpts = ChatDbOpts + { dbFilePrefix :: String, + dbKey :: ScrubbedBytes, + vacuumOnMigration :: Bool + } + +chatDbOptsP :: FilePath -> FilePath -> Parser ChatDbOpts +chatDbOptsP appDir defaultDbName = do + dbFilePrefix <- + strOption + ( long "database" + <> short 'd' + <> metavar "DB_FILE" + <> help "Path prefix to chat and agent database files" + <> value (combine appDir defaultDbName) + <> showDefault + ) + dbKey <- + strOption + ( long "key" + <> short 'k' + <> metavar "KEY" + <> help "Database encryption key/pass-phrase" + <> value "" + ) + disableVacuum <- + switch + ( long "disable-vacuum" + <> help "Do not vacuum database after migrations" + ) + pure ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = not disableVacuum} + +dbString :: ChatDbOpts -> String +dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do + DBOpts + { dbFilePath = dbFilePrefix <> dbSuffix, + dbKey, + keepKey, + vacuum = vacuumOnMigration + } + +chatSuffix :: String +chatSuffix = "_chat.db" + +agentSuffix :: String +agentSuffix = "_agent.db" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts fp key = do + dbFilePrefix <- peekCString fp + dbKey <- BA.convert <$> B.packCString key + pure $ + ChatDbOpts + { dbFilePrefix, + dbKey, + vacuumOnMigration = True + } + +-- used to create new chat controller, +-- at that point database is already opened, and the key in options is not used +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = + ChatDbOpts + { dbFilePrefix, + dbKey = "", + vacuumOnMigration + } + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 934f23007d..9cbc63b0e2 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,8 +1,10 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -42,13 +44,13 @@ import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -69,12 +71,13 @@ import Simplex.Messaging.Version hiding (version) -- 9 - batch sending in direct connections (2024-07-24) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) +-- 12 - support sending and receiving content reports (2025-01-03) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 11 +currentChatVersion = VersionChat 12 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -121,6 +124,10 @@ businessChatsVersion = VersionChat 10 businessChatPrefsVersion :: VersionChat businessChatPrefsVersion = VersionChat 11 +-- support sending and receiving content reports (MCReport message content) +contentReportsVersion :: VersionChat +contentReportsVersion = VersionChat 12 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -211,10 +218,9 @@ instance StrEncoding AppMessageBinary where newtype SharedMsgId = SharedMsgId ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField SharedMsgId where fromField f = SharedMsgId <$> fromField f - -instance ToField SharedMsgId where toField (SharedMsgId m) = toField m +instance ToField SharedMsgId where toField (SharedMsgId m) = toField $ DB.Binary m instance StrEncoding SharedMsgId where strEncode (SharedMsgId m) = strEncode m @@ -246,6 +252,9 @@ data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) +data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text + deriving (Eq, Show) + $(pure []) instance FromJSON LinkContent where @@ -265,6 +274,30 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +instance StrEncoding ReportReason where + strEncode = \case + RRSpam -> "spam" + RRContent -> "content" + RRCommunity -> "community" + RRProfile -> "profile" + RROther -> "other" + RRUnknown t -> encodeUtf8 t + strP = + A.takeTill (== ' ') >>= \case + "spam" -> pure RRSpam + "content" -> pure RRContent + "community" -> pure RRCommunity + "profile" -> pure RRProfile + "other" -> pure RROther + t -> pure $ RRUnknown $ safeDecodeUtf8 t + +instance FromJSON ReportReason where + parseJSON = strParseJSON "ReportReason" + +instance ToJSON ReportReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data ChatMessage e = ChatMessage { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, @@ -410,7 +443,7 @@ instance FromJSON MREmojiChar where mrEmojiChar :: Char -> Either String MREmojiChar mrEmojiChar c - | c `elem` ("👍👎😀😢❤️🚀" :: String) = Right $ MREmojiChar c + | c `elem` ("👍👎😀😂😢❤️🚀✅" :: String) = Right $ MREmojiChar c | otherwise = Left "bad emoji" data FileChunk = FileChunk {chunkNo :: Integer, chunkBytes :: ByteString} | FileChunkCancel @@ -451,8 +484,8 @@ cmToQuotedMsg = \case ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCUnknown_ Text - deriving (Eq) +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCReport_ | MCUnknown_ Text + deriving (Eq, Show) instance StrEncoding MsgContentTag where strEncode = \case @@ -462,6 +495,7 @@ instance StrEncoding MsgContentTag where MCVideo_ -> "video" MCFile_ -> "file" MCVoice_ -> "voice" + MCReport_ -> "report" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ @@ -470,6 +504,7 @@ instance StrEncoding MsgContentTag where "video" -> Right MCVideo_ "voice" -> Right MCVoice_ "file" -> Right MCFile_ + "report" -> Right MCReport_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -480,9 +515,12 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding +instance ToField MsgContentTag where toField = toField . safeDecodeUtf8 . strEncode + data MsgContainer = MCSimple ExtMsgContent | MCQuote QuotedMsg ExtMsgContent + | MCComment MsgRef ExtMsgContent | MCForward ExtMsgContent deriving (Eq, Show) @@ -490,13 +528,9 @@ mcExtMsgContent :: MsgContainer -> ExtMsgContent mcExtMsgContent = \case MCSimple c -> c MCQuote _ c -> c + MCComment _ c -> c MCForward c -> c -isQuote :: MsgContainer -> Bool -isQuote = \case - MCQuote {} -> True - _ -> False - data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -504,6 +538,7 @@ data MsgContent | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} | MCFile Text + | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -518,11 +553,12 @@ msgContentText = \case where msg = "voice message " <> durationText duration MCFile t -> t + MCReport {text, reason} -> + if T.null text then msg else msg <> ": " <> text + where + msg = "report " <> safeDecodeUtf8 (strEncode reason) MCUnknown {text} -> text -toMCText :: MsgContent -> MsgContent -toMCText = MCText . msgContentText - durationText :: Int -> Text durationText duration = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")" @@ -532,16 +568,10 @@ durationText duration = | otherwise = show n msgContentHasText :: MsgContent -> Bool -msgContentHasText = \case - MCText t -> hasText t - MCLink {text} -> hasText text - MCImage {text} -> hasText text - MCVideo {text} -> hasText text - MCVoice {text} -> hasText text - MCFile t -> hasText t - MCUnknown {text} -> hasText text - where - hasText = not . T.null +msgContentHasText = + not . T.null . \case + MCVoice {text} -> text + mc -> msgContentText mc isVoice :: MsgContent -> Bool isVoice = \case @@ -556,6 +586,7 @@ msgContentTag = \case MCVideo {} -> MCVideo_ MCVoice {} -> MCVoice_ MCFile {} -> MCFile_ + MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} @@ -619,7 +650,10 @@ markCompressedBatch = B.cons 'X' parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc + <|> MCComment <$> v .: "parent" <*> mc <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) + -- The support for arbitrary object in "forward" property is added to allow + -- forward compatibility with forwards that include public group links. <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where @@ -654,6 +688,10 @@ instance FromJSON MsgContent where duration <- v .: "duration" pure MCVoice {text, duration} MCFile_ -> MCFile <$> v .: "text" + MCReport_ -> do + text <- v .: "text" + reason <- v .: "reason" + pure MCReport {text, reason} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -666,6 +704,7 @@ unknownMsgType = "unknown message type" msgContainerJSON :: MsgContainer -> J.Object msgContainerJSON = \case MCQuote qm mc -> o $ ("quote" .= qm) : msgContent mc + MCComment ref mc -> o $ ("parent" .= ref) : msgContent mc MCForward mc -> o $ ("forward" .= True) : msgContent mc MCSimple mc -> o $ msgContent mc where @@ -681,6 +720,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.object ["type" .= MCVideo_, "text" .= text, "image" .= image, "duration" .= duration] MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration] MCFile t -> J.object ["type" .= MCFile_, "text" .= t] + MCReport {text, reason} -> J.object ["type" .= MCReport_, "text" .= text, "reason" .= reason] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t @@ -689,6 +729,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.pairs $ "type" .= MCVideo_ <> "text" .= text <> "image" .= image <> "duration" .= duration MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t + MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason instance ToField MsgContent where toField = toField . encodeJSON diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..a2eaad11fe 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -37,7 +37,6 @@ import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC -import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) @@ -71,13 +70,16 @@ import UnliftIO import UnliftIO.Concurrent (forkIO) import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) +remoteFilesFolder :: String +remoteFilesFolder = "simplex_v1_files" + -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 2, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 3, 0, 1] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -342,7 +344,7 @@ storeRemoteFile rhId encrypted_ localPath = do filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) hf_ <- chatReadVar remoteHostsFolder forM_ hf_ $ \hf -> do - let rhf = hf storePath archiveFilesFolder + let rhf = hf storePath remoteFilesFolder hPath = rhf takeFileName filePath' createDirectoryIfMissing True rhf (if encrypt then renameFile else copyFile) filePath hPath @@ -360,7 +362,7 @@ storeRemoteFile rhId encrypted_ localPath = do getRemoteFile :: RemoteHostId -> RemoteFile -> CM () getRemoteFile rhId rf = do c@RemoteHostClient {storePath} <- getRemoteHostClient rhId - dir <- lift $ ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar' remoteHostsFolder) + dir <- lift $ ( storePath remoteFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar' remoteHostsFolder) createDirectoryIfMissing True dir liftRH rhId $ remoteGetFile c dir rf diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 4b0591fb3a..03b4d7a640 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,5 +1,7 @@ +{-# LANGUAGE CPP #-} + module Simplex.Chat.Store - ( SQLiteStore, + ( DBStore, StoreError (..), ChatLockEntity (..), UserMsgReceiptSettings (..), @@ -7,23 +9,20 @@ module Simplex.Chat.Store AutoAccept (..), createChatStore, migrations, -- used in tests - chatStoreFile, - agentStoreFile, withTransaction, ) where -import Data.ByteArray (ScrubbedBytes) -import Simplex.Chat.Store.Migrations import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, MigrationError, SQLiteStore (..), createSQLiteStore, withTransaction) +import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Interface (DBOpts, createDBStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) +#if defined(dbPostgres) +import Simplex.Chat.Store.Postgres.Migrations +#else +import Simplex.Chat.Store.SQLite.Migrations +#endif -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError SQLiteStore) -createChatStore dbPath key keepKey = createSQLiteStore dbPath key keepKey migrations - -chatStoreFile :: FilePath -> FilePath -chatStoreFile = (<> "_chat.db") - -agentStoreFile :: FilePath -> FilePath -agentStoreFile = (<> "_agent.db") +createChatStore :: DBOpts -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore dbCreateOpts = createDBStore dbCreateOpts migrations diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs index ee0dd30183..dbdd538cf4 100644 --- a/src/Simplex/Chat/Store/AppSettings.hs +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Store.AppSettings where @@ -6,10 +7,14 @@ import Control.Monad (join) import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J import Data.Maybe (fromMaybe) -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif saveAppSettings :: DB.Connection -> AppSettings -> IO () saveAppSettings db appSettings = do diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 6783dae99e..589b8e39f2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -25,8 +26,6 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (catMaybes, fromMaybe) -import Database.SQLite.Simple (Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files @@ -35,9 +34,17 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (eitherToMaybe) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif getChatLockEntity :: DB.Connection -> AgentConnId -> ExceptT StoreError IO ChatLockEntity getChatLockEntity db agentConnId = do @@ -103,47 +110,49 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, - p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do - gm <- ExceptT $ firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? - |] - (groupMemberId, userId, userContactId) + gm <- + ExceptT $ + firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupMemberId, userId, userContactId) liftIO $ bitraverse (addGroupChatTags db) pure gm toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) toGroupAndMember c (groupInfoRow :. memberRow) = @@ -212,7 +221,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 - ) + ) c |] (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7697f5d5d8..44ee662c75 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} @@ -81,6 +82,9 @@ module Simplex.Chat.Store.Direct setContactChatDeleted, getDirectChatTags, updateDirectChatTags, + setDirectChatTTL, + getDirectChatTTL, + getUserContactsToExpire ) where @@ -93,20 +97,26 @@ import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util ((<$$>)) import Simplex.Messaging.Version +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection getPendingContactConnection db userId connId = do @@ -160,9 +170,9 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) - :. (customUserProfileId, isJust groupLinkId, groupLinkId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, xContactId) + :. (customUserProfileId, BI (isJust groupLinkId), groupLinkId) + :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -183,26 +193,27 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash = do getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db vr user@User {userId} cReqHash = do - ct_ <- maybeFirstRow (toContact vr user []) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, cReqHash, CSActive) + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, cReqHash, CSActive) mapM (addDirectChatTags db) ct_ createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection @@ -218,8 +229,8 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ( (userId, acId, cReq, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) + :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -255,6 +266,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -342,31 +354,33 @@ deleteContactProfile_ db userId contactId = deleteUnusedProfile_ :: DB.Connection -> UserId -> ProfileId -> IO () deleteUnusedProfile_ db userId profileId = - DB.executeNamed + DB.execute db [sql| DELETE FROM contact_profiles - WHERE user_id = :user_id AND contact_profile_id = :profile_id + WHERE user_id = ? AND contact_profile_id = ? AND 1 NOT IN ( SELECT 1 FROM connections - WHERE user_id = :user_id AND custom_user_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contacts - WHERE user_id = :user_id AND contact_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND contact_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id - AND (member_profile_id = :profile_id OR contact_profile_id = :profile_id) + WHERE user_id = ? + AND (member_profile_id = ? OR contact_profile_id = ?) LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profileId] + ( (userId, profileId, userId, profileId, userId, profileId) + :. (userId, profileId, userId, profileId, profileId) + ) updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact updateContactProfile db user@User {userId} c p' @@ -465,14 +479,14 @@ updateContactUsed db User {userId} Contact {contactId} = do updateContactUnreadChat :: DB.Connection -> User -> Contact -> Bool -> IO () updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) + DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (BI unreadChat, updatedAt, userId, contactId) setUserChatsRead :: DB.Connection -> User -> IO () setUserChatsRead db User {userId} = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) - DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) - DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) + DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) + DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) + DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ?" (CISRcvRead, updatedAt, userId, CISRcvNew) updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact @@ -491,7 +505,7 @@ updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (unreadChat, updatedAt, userId, groupId) + DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (BI unreadChat, updatedAt, userId, groupId) setConnectionVerified :: DB.Connection -> User -> Int64 -> Maybe Text -> IO () setConnectionVerified db User {userId} connId code = do @@ -635,40 +649,42 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact created_at, updated_at, xcontact_id, pq_support) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ( (userContactLinkId, invId, minV, maxV, profileId, ldn, userId) + ( (userContactLinkId, Binary invId, minV, maxV, profileId, ldn, userId) :. (currentTs, currentTs, xContactId_, pqSup) ) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = do - ct_ <- maybeFirstRow (toContact vr user []) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, xContactId) + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, xContactId) mapM (addDirectChatTags db) ct_ getGroupInfo' :: XContactId -> IO (Maybe GroupInfo) getGroupInfo' xContactId = do - g_ <- maybeFirstRow (toGroupInfo vr userContactId []) $ - DB.query - db - (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (xContactId, userId, userContactId) + g_ <- + maybeFirstRow (toGroupInfo vr userContactId []) $ + DB.query + db + (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) getContactRequestByXContactId xContactId = @@ -702,7 +718,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, pqSup, minV, maxV, currentTs, userId, cReqId) + (Binary invId, pqSup, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do DB.execute @@ -712,7 +728,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) + (Binary invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) safeDeleteLDN db user oldLdn where updateProfile currentTs = @@ -803,7 +819,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} DB.execute db "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id, contact_used) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId, contactUsed) + (userId, localDisplayName, profileId, BI True, userPreferences, createdAt, createdAt, createdAt, xContactId, BI contactUsed) contactId <- insertedRowId db DB.execute db "UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ?" (contactId, userId, localDisplayName) conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId ConnNew connChatVersion cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode pqSup @@ -826,6 +842,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -841,7 +858,7 @@ updateContactAccepted db User {userId} Contact {contactId} contactUsed = DB.execute db "UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ?" - (contactUsed, userId, contactId) + (BI contactUsed, userId, contactId) getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -861,7 +878,7 @@ getContact_ db vr user@User {userId} contactId deleted = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -882,12 +899,12 @@ getContact_ db vr user@User {userId} contactId deleted = do WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 - ) + ) cc ) OR c.connection_id IS NULL ) |] - (userId, contactId, deleted, ConnReady, ConnSndReady) + (userId, contactId, BI deleted, ConnReady, ConnSndReady) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User getUserByContactRequestId db contactRequestId = @@ -897,16 +914,16 @@ getUserByContactRequestId db contactRequestId = getPendingContactConnections :: DB.Connection -> User -> IO [PendingContactConnection] getPendingContactConnections db User {userId} = do map toPendingContactConnection - <$> DB.queryNamed + <$> DB.query db [sql| SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at FROM connections - WHERE user_id = :user_id - AND conn_type = :conn_type + WHERE user_id = ? + AND conn_type = ? AND contact_id IS NULL |] - [":user_id" := userId, ":conn_type" := ConnContact] + (userId, ConnContact) getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] getContactConnections db vr userId Contact {contactId} = @@ -945,9 +962,13 @@ getConnectionById db vr User {userId} connId = ExceptT $ do getConnectionsContacts :: DB.Connection -> [ConnId] -> IO [ContactRef] getConnectionsContacts db agentConnIds = do - DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" - DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" - DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds + DB.execute_ db "DROP TABLE IF EXISTS temp_conn_ids" +#if defined(dbPostgres) + DB.execute_ db "CREATE TABLE temp_conn_ids (conn_id BYTEA)" +#else + DB.execute_ db "CREATE TABLE temp_conn_ids (conn_id BLOB)" +#endif + DB.executeMany db "INSERT INTO temp_conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds conns <- map toContactRef <$> DB.query @@ -956,12 +977,12 @@ getConnectionsContacts db agentConnIds = do SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) AND c.conn_type = ? AND ct.deleted = 0 |] (Only ConnContact) - DB.execute_ db "DROP TABLE temp.conn_ids" + DB.execute_ db "DROP TABLE temp_conn_ids" pure conns where toContactRef :: (ContactId, Int64, ConnId, ContactName) -> ContactRef @@ -986,7 +1007,7 @@ updateConnectionStatus_ db connId connStatus = do updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = - DB.execute db "UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ?" (enableNtfs, sendRcpts, favorite, userId, contactId) + DB.execute db "UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ?" (enableNtfs, BI <$> sendRcpts, BI favorite, userId, contactId) setConnConnReqInv :: DB.Connection -> User -> Int64 -> ConnReqInvitation -> IO () setConnConnReqInv db User {userId} connId connReq = do @@ -1025,7 +1046,7 @@ setContactUIThemes db User {userId} Contact {contactId} uiThemes = do setContactChatDeleted :: DB.Connection -> User -> Contact -> Bool -> IO () setContactChatDeleted db User {userId} Contact {contactId} chatDeleted = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (chatDeleted, updatedAt, userId, contactId) + DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (BI chatDeleted, updatedAt, userId, contactId) updateDirectChatTags :: DB.Connection -> ContactId -> [ChatTagId] -> IO () updateDirectChatTags db contactId tIds = do @@ -1062,3 +1083,19 @@ addDirectChatTags :: DB.Connection -> Contact -> IO Contact addDirectChatTags db ct = do chatTags <- getDirectChatTags db $ contactId' ct pure (ct :: Contact) {chatTags} + +setDirectChatTTL :: DB.Connection -> ContactId -> Maybe Int64 -> IO () +setDirectChatTTL db ctId ttl = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ?" (ttl, updatedAt, ctId) + +getDirectChatTTL :: DB.Connection -> ContactId -> IO (Maybe Int64) +getDirectChatTTL db ctId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1" (Only ctId) + +getUserContactsToExpire :: DB.Connection -> User -> Int64 -> IO [ContactId] +getUserContactsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 2c02d872b1..95e169e400 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -96,9 +97,6 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality import Data.Word (Word32) -import Database.SQLite.Simple (Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) -import Database.SQLite.Simple.ToField (ToField) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol @@ -109,8 +107,9 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Util (week) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -118,6 +117,15 @@ import Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Version import System.FilePath (takeFileName) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +import Database.PostgreSQL.Simple.ToField (ToField) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +import Database.SQLite.Simple.ToField (ToField) +#endif getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -283,7 +291,7 @@ createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fi DB.execute db "INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (userId, fileDescrText, fileDescrPartNo, fileDescrComplete, currentTs, currentTs) + (userId, fileDescrText, fileDescrPartNo, BI fileDescrComplete, currentTs, currentTs) fileDescrId <- insertedRowId db DB.execute db @@ -308,7 +316,7 @@ updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDesc SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? WHERE user_id = ? AND file_descr_id = ? |] - (rfdText, 1 :: Int, True, currentTs, userId, fileDescrId) + (rfdText, 1 :: Int, BI True, currentTs, userId, fileDescrId) updateCIFileStatus db user fileId $ CIFSSndTransfer 1 1 updateSndFileStatus db sft FSConnected @@ -574,7 +582,7 @@ createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, file DB.execute db "INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (userId, fileDescrText, fileDescrPartNo, fileDescrComplete, currentTs, currentTs) + (userId, fileDescrText, fileDescrPartNo, BI fileDescrComplete, currentTs, currentTs) insertedRowId db pure RcvFileDescr {fileDescrId, fileDescrPartNo, fileDescrText, fileDescrComplete} @@ -607,7 +615,7 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? WHERE file_descr_id = ? |] - (fileDescrText', fileDescrPartNo, fileDescrComplete, fileDescrId) + (fileDescrText', fileDescrPartNo, BI fileDescrComplete, fileDescrId) pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete} getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr @@ -650,8 +658,8 @@ getRcvFileDescrBySndFileId_ db fileId = |] (Only fileId) -toRcvFileDescr :: (Int64, Text, Int, Bool) -> RcvFileDescr -toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete) = +toRcvFileDescr :: (Int64, Text, Int, BoolInt) -> RcvFileDescr +toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, BI fileDescrComplete) = RcvFileDescr {fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete} updateRcvFileAgentId :: DB.Connection -> FileTransferId -> Maybe AgentRcvFileId -> IO () @@ -682,8 +690,8 @@ getRcvFileTransfer_ db userId fileId = do FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN connections c ON r.file_id = c.rcv_file_id - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -692,9 +700,9 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool, Bool) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe Int64, Maybe AgentConnId) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (connId_, agentConnId_)) = case contactName_ <|> memberName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -717,7 +725,7 @@ getRcvFileTransfer_ db userId fileId = do rfi_ = case (filePath_, connId_, agentConnId_) of (Just filePath, connId, agentConnId) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId} _ -> pure Nothing - cancelled = fromMaybe False cancelled_ + cancelled = maybe False unBI cancelled_ acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do @@ -726,7 +734,7 @@ acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus f DB.execute db "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, subMode == SMOnlyCreate) + (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, BI (subMode == SMOnlyCreate)) connId <- insertedRowId db setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db vr user fileId @@ -763,7 +771,7 @@ acceptRcvFT_ db User {userId} fileId filePath userApprovedRelays rcvFileInline c DB.execute db "UPDATE rcv_files SET user_approved_relays = ?, rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ?" - (userApprovedRelays, rcvFileInline, FSAccepted, currentTs, fileId) + (BI userApprovedRelays, rcvFileInline, FSAccepted, currentTs, fileId) setRcvFileToReceive :: DB.Connection -> FileTransferId -> Bool -> Maybe CryptoFileArgs -> IO () setRcvFileToReceive db fileId userApprovedRelays cfArgs_ = do @@ -775,7 +783,7 @@ setRcvFileToReceive db fileId userApprovedRelays cfArgs_ = do SET to_receive = 1, user_approved_relays = ?, updated_at = ? WHERE file_id = ? |] - (userApprovedRelays, currentTs, fileId) + (BI userApprovedRelays, currentTs, fileId) forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs setFileCryptoArgs :: DB.Connection -> FileTransferId -> CryptoFileArgs -> IO () @@ -928,8 +936,8 @@ getSndFileTransfers_ db userId fileId = FROM snd_files s JOIN files f USING (file_id) JOIN connections c USING (connection_id) - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = s.group_member_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -955,11 +963,11 @@ getFileTransferMeta_ db userId fileId = |] (userId, fileId) where - fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool, Maybe FileTransferId) -> FileTransferMeta - fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) = + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, BoolInt, Maybe Text, Maybe BoolInt, Maybe FileTransferId) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, BI agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) = let cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_ - in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_} + in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = maybe False unBI cancelled_} lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta] lookupFileTransferRedirectMeta db User {userId} fileId = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 98173800cc..1d1a715b78 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -49,12 +50,14 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, + getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, deleteGroupItemsAndMembers, deleteGroup, getUserGroups, + getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, @@ -124,6 +127,10 @@ module Simplex.Chat.Store.Groups setGroupUIThemes, updateGroupChatTags, getGroupChatTags, + setGroupChatTTL, + getGroupChatTTL, + getUserGroupsToExpire, + updateGroupAlias, ) where @@ -140,8 +147,6 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Protocol (groupForwardVersion) import Simplex.Chat.Store.Direct @@ -151,8 +156,9 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) @@ -160,7 +166,10 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) + +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = @@ -174,7 +183,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} DB.execute db "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) + (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, BI True, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff @@ -253,41 +262,42 @@ setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = do - gm <- ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? - |] - (userId, groupMemberId, userId, userContactId) + gm <- + ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (userId, groupMemberId, userId, userContactId) liftIO $ bitraverse (addGroupChatTags db) pure gm where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -318,7 +328,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc created_at, updated_at, chat_ts, user_member_profile_sent_at) VALUES (?,?,?,?,?,?,?,?) |] - (ldn, userId, profileId, True, currentTs, currentTs, currentTs, currentTs) + (ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr @@ -328,6 +338,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc { groupId, localDisplayName = ldn, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -338,6 +349,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing } @@ -386,7 +398,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -397,6 +409,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ { groupId, localDisplayName, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -407,6 +420,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing }, @@ -531,7 +545,7 @@ createGroupInvitedViaLink created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -575,6 +589,51 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members +getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup +getGroupToSubscribe db User {userId, userContactId} groupId = do + shortInfo <- getGroupInfoToSubscribe + members <- liftIO getGroupMembersToSubscribe + pure $ ShortGroup shortInfo members + where + getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo + getGroupInfoToSubscribe = ExceptT $ do + firstRow toInfo (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + |] + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo + toInfo (groupName, membershipStatus) = + ShortGroupInfo groupId groupName membershipStatus + getGroupMembersToSubscribe :: IO [ShortGroupMember] + getGroupMembersToSubscribe = do + map toShortMember + <$> DB.query + db + [sql| + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + |] + (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember + toShortMember (groupMemberId, localDisplayName, agentConnId) = + ShortGroupMember groupMemberId groupId localDisplayName agentConnId + deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) @@ -629,26 +688,35 @@ getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds +getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] +getUserGroupsToSubscribe db user@User {userId} = do + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds + getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - g_ <- map (toGroupInfo vr userContactId []) - <$> DB.query - db - [sql| - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%') - |] - (userId, userContactId, search, search, search) + g_ <- + map (toGroupInfo vr userContactId []) + <$> DB.query + db + [sql| + SELECT + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu USING (group_id) + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + |] + (userId, userContactId, search, search, search) mapM (addGroupChatTags db) g_ where search = fromMaybe "" search_ @@ -747,8 +815,16 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, groupId, userId, userContactId) + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") + (userId, userId, groupId, userContactId) + +getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)") + (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -949,7 +1025,7 @@ createBusinessRequestGroup created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_xcontact_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (profileId, localDisplayName, userId, True, currentTs, currentTs, currentTs, currentTs, BCCustomer, xContactId) + (profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer, xContactId) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr @@ -1184,57 +1260,47 @@ createIntroductions db chatV members toMember = do updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_member_intros - SET intro_status = :intro_status, updated_at = :updated_at - WHERE group_member_intro_id = :intro_id + SET intro_status = ?, updated_at = ? + WHERE group_member_intro_id = ? |] - [":intro_status" := introStatus, ":updated_at" := currentTs, ":intro_id" := introId] + (introStatus, currentTs, introId) saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} = do intro <- getIntroduction db reMember toMember liftIO $ do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_member_intros - SET intro_status = :intro_status, - group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info, - updated_at = :updated_at - WHERE group_member_intro_id = :intro_id + SET intro_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_intro_id = ? |] - [ ":intro_status" := GMIntroInvReceived, - ":group_queue_info" := groupConnReq, - ":direct_queue_info" := directConnReq introInv, - ":updated_at" := currentTs, - ":intro_id" := introId intro - ] + (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> IO () saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET member_status = :member_status, - group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info, - updated_at = :updated_at - WHERE group_member_id = :group_member_id + SET member_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_id = ? |] - [ ":member_status" := GSMemIntroInvited, - ":group_queue_info" := groupConnReq, - ":direct_queue_info" := directConnReq, - ":updated_at" := currentTs, - ":group_member_id" := groupMemberId - ] + (GSMemIntroInvited, groupConnReq, directConnReq, currentTs, groupMemberId) getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro getIntroduction db reMember toMember = ExceptT $ do @@ -1355,14 +1421,14 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = pure contactId updateMember_ :: Int64 -> UTCTime -> IO () updateMember_ contactId ts = - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET contact_id = :contact_id, updated_at = :updated_at - WHERE group_member_id = :group_member_id + SET contact_id = ?, updated_at = ? + WHERE group_member_id = ? |] - [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] + (contactId, ts, groupMemberId) createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = @@ -1370,42 +1436,43 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do - gm_ <- maybeFirstRow toGroupAndMember $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - |] - (userId, userId, contactId, userContactId) + gm_ <- + maybeFirstRow toGroupAndMember $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- via GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 + |] + (userId, userId, contactId, userContactId) mapM (bitraverse (addGroupChatTags db) pure) gm_ where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -1641,7 +1708,7 @@ createSentProbe db gVar userId to = DB.execute db "INSERT INTO sent_probes (contact_id, group_member_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (ctId, gmId, probe, userId, currentTs, currentTs) + (ctId, gmId, Binary probe, userId, currentTs, currentTs) (Probe probe,) <$> insertedRowId db createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrMember -> IO () @@ -1667,13 +1734,13 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do LEFT JOIN groups g ON g.group_id = m.group_id WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL |] - (userId, probeHash) + (userId, Binary probeHash) currentTs <- getCurrentTime let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) + (ctId, gmId, Binary probe, Binary probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' where @@ -1699,13 +1766,13 @@ matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do LEFT JOIN groups g ON g.group_id = m.group_id WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL |] - (userId, probeHash) + (userId, Binary probeHash) currentTs <- getCurrentTime let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (ctId, gmId, probeHash, userId, currentTs, currentTs) + (ctId, gmId, Binary probeHash, userId, currentTs, currentTs) pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds matchSentProbe :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) @@ -1727,7 +1794,7 @@ matchSentProbe db vr user@User {userId} _from (Probe probe) = do WHERE s.user_id = ? AND s.probe = ? AND (h.contact_id = ? OR h.group_member_id = ?) |] - (userId, probe, ctId, gmId) + (userId, Binary probe, ctId, gmId) getContactOrMember_ :: DB.Connection -> VersionRangeChat -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) getContactOrMember_ db vr user ids = @@ -1768,22 +1835,18 @@ mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keep db "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" (toContactId, currentTs, fromContactId, userId) - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id + SET contact_id = ?, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), + updated_at = ? + WHERE contact_id = ? + AND user_id = ? |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] + (toContactId, toContactId, toContactId, currentTs, fromContactId, userId) deleteContactProfile_ db userId fromContactId DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) deleteUnusedDisplayName_ db userId fromLDN @@ -1858,41 +1921,44 @@ associateContactWithMemberRecord deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = - DB.executeNamed + DB.execute db [sql| DELETE FROM display_names - WHERE user_id = :user_id AND local_display_name = :local_display_name + WHERE user_id = ? AND local_display_name = ? AND 1 NOT IN ( SELECT 1 FROM users - WHERE local_display_name = :local_display_name LIMIT 1 + WHERE local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contacts - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM groups - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM user_contact_links - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) |] - [":user_id" := userId, ":local_display_name" := localDisplayName] + ( (userId, localDisplayName, localDisplayName, userId, localDisplayName, userId, localDisplayName) + :. (userId, localDisplayName, userId, localDisplayName, userId, localDisplayName) + :. (userId, localDisplayName) + ) deleteOldProbes :: DB.Connection -> UTCTime -> IO () deleteOldProbes db createdAtCutoff = do @@ -1902,7 +1968,7 @@ deleteOldProbes db createdAtCutoff = do updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = - DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) + DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, BI <$> sendRcpts, BI favorite, userId, groupId) updateGroupMemberSettings :: DB.Connection -> User -> GroupId -> GroupMemberId -> GroupMemberSettings -> IO () updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {showMessages} = do @@ -1914,7 +1980,7 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh SET show_messages = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_member_id = ? |] - (showMessages, currentTs, userId, gId, gMemberId) + (BI showMessages, currentTs, userId, gId, gMemberId) updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO () updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do @@ -2016,8 +2082,8 @@ createMemberContact contact_group_member_id, contact_grp_inv_sent, created_at, updated_at, chat_ts ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, localDisplayName, memberContactProfileId, True, userPreferences, True) - :. (groupMemberId, False, currentTs, currentTs, currentTs) + ( (userId, localDisplayName, memberContactProfileId, BI True, userPreferences, BI True) + :. (groupMemberId, BI False, currentTs, currentTs, currentTs) ) contactId <- insertedRowId db DB.execute @@ -2032,8 +2098,8 @@ createMemberContact conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) - :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, BI True, contactId, customUserProfileId) + :. (connChatVersion, minV, maxV, currentTs, currentTs, BI (subMode == SMOnlyCreate)) ) connId <- insertedRowId db let ctConn = @@ -2063,7 +2129,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2084,7 +2150,7 @@ setContactGrpInvSent db Contact {contactId} xGrpDirectInvSent = do DB.execute db "UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ?" - (xGrpDirectInvSent, currentTs, contactId) + (BI xGrpDirectInvSent, currentTs, contactId) createMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> GroupMember -> Connection -> SubscriptionMode -> IO (Contact, GroupMember) createMemberContactInvited @@ -2100,7 +2166,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -2114,7 +2180,7 @@ createMemberContactInvited created_at, updated_at, chat_ts ) VALUES (?,?,?,?,?,?,?,?,?) |] - ( (userId, memberLDN, memberContactProfileId, True, userPreferences, True) + ( (userId, memberLDN, memberContactProfileId, BI True, userPreferences, BI True) :. (currentTs, currentTs, currentTs) ) contactId <- insertedRowId db @@ -2166,7 +2232,7 @@ createMemberContactConn_ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) - :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, BI (subMode == SMOnlyCreate)) ) connId <- insertedRowId db setCommandConnId db user cmdId connId @@ -2235,7 +2301,7 @@ updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = - ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ + ExceptT . firstRow fromOnlyBI (SEGroupMemberNotFound mId) $ DB.query db "SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?" (Only mId) setXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> Bool -> IO () @@ -2244,7 +2310,7 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do DB.execute db "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" - (xGrpLinkMemReceived, currentTs, mId) + (BI xGrpLinkMemReceived, currentTs, mId) createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do @@ -2339,3 +2405,28 @@ untagGroupChat db groupId tId = WHERE group_id = ? AND chat_tag_id = ? |] (groupId, tId) + +setGroupChatTTL :: DB.Connection -> GroupId -> Maybe Int64 -> IO () +setGroupChatTTL db gId ttl = do + updatedAt <- getCurrentTime + DB.execute + db + "UPDATE groups SET chat_item_ttl = ?, updated_at = ? WHERE group_id = ?" + (ttl, updatedAt, gId) + +getGroupChatTTL :: DB.Connection -> GroupId -> IO (Maybe Int64) +getGroupChatTTL db gId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM groups WHERE group_id = ? LIMIT 1" (Only gId) + +getUserGroupsToExpire :: DB.Connection -> User -> Int64 -> IO [GroupId] +getUserGroupsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" + +updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo +updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) + pure (g :: GroupInfo) {localAlias = localAlias} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..a828a30925 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} @@ -58,6 +59,7 @@ module Simplex.Chat.Store.Messages markGroupChatItemDeleted, markGroupChatItemBlocked, markGroupCIBlockedByAdmin, + markMessageReportsDeleted, deleteLocalChatItem, updateDirectChatItemsRead, getDirectUnreadTimedItems, @@ -105,6 +107,7 @@ module Simplex.Chat.Store.Messages getTimedItems, getChatItemTTL, setChatItemTTL, + getChatTTLCount, getContactExpiredFileInfo, deleteContactExpiredCIs, getGroupExpiredFileInfo, @@ -139,8 +142,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -152,12 +153,20 @@ import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif deleteContactCIs :: DB.Connection -> User -> Contact -> IO () deleteContactCIs db user@User {userId} ct@Contact {contactId} = do @@ -198,7 +207,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent encodeMessage = shared_msg_id, shared_msg_id_user, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?) |] - (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, sharedMsgId, Just True, createdAt, createdAt) + (MDSnd, toCMEventTag chatMsgEvent, DB.Binary msgBody, connId_, groupId_, DB.Binary sharedMsgId, Just (BI True), createdAt, createdAt) msgId <- insertedRowId db pure $ Right SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} where @@ -283,7 +292,7 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} share (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) + (MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) msgId <- insertedRowId db pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} @@ -405,21 +414,22 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta - item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) + ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) @@ -450,11 +460,11 @@ getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirectio getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) - CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {memberId = senderMemberId} -> + CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId mId + | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId | otherwise -> getGroupChatItemQuote_ groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where @@ -466,7 +476,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ?" - (userId, contactId, msgId, userSent) + (userId, contactId, msgId, BI userSent) where ciQuoteDirect :: Maybe ChatItemId -> CIQuote 'CTDirect ciQuoteDirect = (`ciQuote` if userSent then CIQDirectSnd else CIQDirectRcv) @@ -477,17 +487,17 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL" (userId, groupId, msgId, MDSnd) - getGroupChatItemId_ :: Int64 -> MemberId -> IO (Maybe ChatItemId) - getGroupChatItemId_ groupId mId = + getGroupChatItemId_ :: Int64 -> GroupMemberId -> IO (Maybe ChatItemId) + getGroupChatItemId_ groupId groupMemberId = maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" - (userId, groupId, msgId, MDRcv, mId) + (userId, groupId, msgId, MDRcv, groupMemberId) getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) getGroupChatItemQuote_ groupId mId = do ciQuoteGroup - <$> DB.queryNamed + <$> DB.query db [sql| SELECT i.chat_item_id, @@ -501,10 +511,10 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe LEFT JOIN chat_items i ON i.user_id = m.user_id AND i.group_id = m.group_id AND m.group_member_id = i.group_member_id - AND i.shared_msg_id = :msg_id - WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id + AND i.shared_msg_id = ? + WHERE m.user_id = ? AND m.group_id = ? AND m.member_id = ? |] - [":user_id" := userId, ":group_id" := groupId, ":member_id" := mId, ":msg_id" := msgId] + (msgId, userId, groupId, mId) where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing @@ -548,16 +558,10 @@ data ChatPreviewData (c :: ChatType) where data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreviewData c) -paginationByTimeFilter :: PaginationByTime -> (Query, [NamedParam]) -paginationByTimeFilter = \case - PTLast count -> ("\nORDER BY ts DESC LIMIT :count", [":count" := count]) - PTAfter ts count -> ("\nAND ts > :ts ORDER BY ts ASC LIMIT :count", [":ts" := ts, ":count" := count]) - PTBefore ts count -> ("\nAND ts < :ts ORDER BY ts DESC LIMIT :count", [":ts" := ts, ":count" := count]) - -type ChatStatsRow = (Int, ChatItemId, Bool) +type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -568,84 +572,86 @@ findDirectChatPreviews_ db User {userId} pagination clq = ACPD SCTDirect $ DirectChatPD ts contactId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT ct.contact_id, ct.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat FROM contacts ct - LEFT JOIN ( - SELECT contact_id, chat_item_id, MAX(created_at) - FROM chat_items - WHERE user_id = :user_id AND contact_id IS NOT NULL - GROUP BY contact_id - ) LastItems ON LastItems.contact_id = ct.contact_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND contact_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? GROUP BY contact_id ) ChatStats ON ChatStats.contact_id = ct.contact_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ct.favorite = 1 - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.favorite = 1 - OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQSearch {search} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ( - ct.local_display_name LIKE '%' || :search || '%' - OR cp.display_name LIKE '%' || :search || '%' - OR cp.full_name LIKE '%' || :search || '%' - OR cp.local_alias LIKE '%' || :search || '%' - ) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1" + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQSearch {search} -> do + let q = + baseQuery + <> " " + <> [sql| + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ( + LOWER(ct.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.local_alias) LIKE '%' || LOWER(?) || '%' + ) + |] + p = baseParams :. (userId, search, search, search, search) + queryWithPagination q p + queryWithPagination :: ToRow p => Query -> p -> IO [(ContactId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY ct.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ?") (params :. (ts, count)) getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do @@ -664,84 +670,93 @@ findGroupChatPreviews_ db User {userId} pagination clq = ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT g.group_id, g.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat FROM groups g - LEFT JOIN ( - SELECT group_id, chat_item_id, MAX(item_ts) - FROM chat_items - WHERE user_id = :user_id AND group_id IS NOT NULL - GROUP BY group_id - ) LastItems ON LastItems.group_id = g.group_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND group_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND g.favorite = 1 - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND (g.favorite = 1 - OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQSearch {search} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = :user_id - AND ( - g.local_display_name LIKE '%' || :search || '%' - OR gp.display_name LIKE '%' || :search || '%' - OR gp.full_name LIKE '%' || :search || '%' - OR gp.description LIKE '%' || :search || '%' - ) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE g.user_id = ?" + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE g.user_id = ? + AND g.favorite = 1 + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE g.user_id = ? + AND (g.favorite = 1 + OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQSearch {search} -> do + let q = + baseQuery + <> " " + <> [sql| + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? + AND ( + LOWER(g.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + |] + p = baseParams :. (userId, search, search, search, search) + queryWithPagination q p + queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY g.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ?") (params :. (ts, count)) getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do @@ -760,68 +775,71 @@ findLocalChatPreviews_ db User {userId} pagination clq = ACPD SCTLocal $ LocalChatPD ts noteFolderId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT nf.note_folder_id, nf.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), nf.unread_chat + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat FROM note_folders nf - LEFT JOIN ( - SELECT note_folder_id, chat_item_id, MAX(created_at) - FROM chat_items - WHERE user_id = :user_id AND note_folder_id IS NOT NULL - GROUP BY note_folder_id - ) LastItems ON LastItems.note_folder_id = nf.note_folder_id LEFT JOIN ( SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND note_folder_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? GROUP BY note_folder_id ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND nf.favorite = 1 - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND (nf.favorite = 1 - OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE nf.user_id = ?" + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE nf.user_id = ? + AND nf.favorite = 1 + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> " " + <> [sql| + WHERE nf.user_id = ? + AND (nf.favorite = 1 + OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] + p = baseParams :. Only userId + queryWithPagination q p CLQSearch {} -> pure [] + queryWithPagination :: ToRow p => Query -> p -> IO [(NoteFolderId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY nf.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ?") (params :. (ts, count)) getLocalChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTLocal -> ExceptT StoreError IO AChat getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do @@ -864,90 +882,84 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex let itemDeleted' = case itemDeleted of DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTLocal deletedTs) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> query "" - CLQFilters {favorite = True, unread = True} -> query "" - CLQSearch {search} -> query search + CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" + CLQSearch {search} -> map toPreview <$> getPreviews search where - (pagQuery, pagParams) = paginationByTimeFilter pagination - query search = - map toPreview - <$> DB.queryNamed - db - ( [sql| - SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, - cr.created_at, cr.updated_at as ts, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id - JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id - JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id - WHERE cr.user_id = :user_id - AND uc.user_id = :user_id - AND uc.local_display_name = '' - AND uc.group_id IS NULL - AND ( - cr.local_display_name LIKE '%' || :search || '%' - OR p.display_name LIKE '%' || :search || '%' - OR p.full_name LIKE '%' || :search || '%' - ) - |] - <> pagQuery + query = + [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' ) - ([":user_id" := userId, ":search" := search] <> pagParams) + |] + params search = (userId, userId, search, search, search) + getPreviews search = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) toPreview :: ContactRequestRow -> AChatPreviewData toPreview cReqRow = let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow - stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat getContactConnectionChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" CLQFilters {favorite = True, unread = False} -> pure [] CLQFilters {favorite = False, unread = True} -> pure [] CLQFilters {favorite = True, unread = True} -> pure [] - CLQSearch {search} -> query search + CLQSearch {search} -> map toPreview <$> getPreviews search where - (pagQuery, pagParams) = paginationByTimeFilter pagination - query search = - map toPreview - <$> DB.queryNamed - db - ( [sql| - SELECT - connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at as ts - FROM connections - WHERE user_id = :user_id - AND conn_type = :conn_contact - AND conn_status != :conn_status - AND contact_id IS NULL - AND conn_level = 0 - AND via_contact IS NULL - AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) - AND local_alias LIKE '%' || :search || '%' - |] - <> pagQuery - ) - ([":user_id" := userId, ":conn_contact" := ConnContact, ":conn_status" := ConnPrepared, ":search" := search] <> pagParams) + query = + [sql| + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + |] + params search = (userId, ConnContact, ConnPrepared, search) + getPreviews search = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY updated_at DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND updated_at > ? ORDER BY updated_at ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND updated_at < ? ORDER BY updated_at DESC LIMIT ?") (params search :. (ts, count)) toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData toPreview connRow = let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow - stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] emptyChatStats in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) @@ -966,11 +978,10 @@ getDirectChat db vr user contactId pagination search_ = do -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) getDirectChatLast_ db user ct count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} ciIds <- getDirectChatItemIdsLast_ db user ct count search ts <- getCurrentTime cis <- mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) (reverse cis) stats + pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = @@ -980,7 +991,7 @@ getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY created_at DESC, chat_item_id DESC LIMIT ? |] @@ -1030,12 +1041,11 @@ getDirectChatItemLast db user@User {userId} contactId = do getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterCI <- getDirectChatItem db user contactId afterId ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) cis stats + pure $ Chat (DirectChat ct) cis emptyChatStats getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = @@ -1045,7 +1055,7 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) ORDER BY created_at ASC, chat_item_id ASC LIMIT ? @@ -1054,12 +1064,11 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeCI <- getDirectChatItem db user contactId beforeId ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) (reverse cis) stats + pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = @@ -1069,7 +1078,7 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) ORDER BY created_at DESC, chat_item_id DESC LIMIT ? @@ -1102,7 +1111,7 @@ getDirectChatInitial_ db user ct count = do liftIO (getContactMinUnreadId_ db user ct) >>= \case Just minUnreadItemId -> do unreadCount <- liftIO $ getContactUnreadCount_ db user ct - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + let stats = emptyChatStats {unreadCount, minUnreadItemId} getDirectChatAround' db user ct minUnreadItemId count "" stats Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct count "" @@ -1110,7 +1119,7 @@ getContactStats_ :: DB.Connection -> User -> Contact -> IO ChatStats getContactStats_ db user ct = do minUnreadItemId <- fromMaybe 0 <$> getContactMinUnreadId_ db user ct unreadCount <- getContactUnreadCount_ db user ct - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + pure emptyChatStats {unreadCount, minUnreadItemId} getContactMinUnreadId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) getContactMinUnreadId_ db User {userId} Contact {contactId} = @@ -1147,87 +1156,101 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id AND item_status = :rcv_new - AND created_at > :created_at + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id AND item_status = :rcv_new - AND created_at = :created_at AND chat_item_id > :item_id - ) + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":rcv_new" := CISRcvNew, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, contactId, CISRcvNew, ciCreatedAt afterCI) + :. (userId, contactId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id - AND created_at > :created_at + WHERE user_id = ? AND contact_id = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id - AND created_at = :created_at AND chat_item_id > :item_id - ) + WHERE user_id = ? AND contact_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, contactId, ciCreatedAt afterCI) + :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) + ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId pagination search_ = do +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db vr user groupId contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId case pagination of - CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g count search - CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g afterId count search - CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g beforeId count search - CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search + CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search emptyChatStats + CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g contentFilter afterId count search + CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g contentFilter beforeId count search + CPAround aroundId count -> getGroupChatAround_ db user g contentFilter aroundId count search CPInitial count -> do unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" - getGroupChatInitial_ db user g count + getGroupChatInitial_ db user g contentFilter count -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) -getGroupChatLast_ db user g count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - ciIds <- getGroupChatItemIdsLast_ db user g count search +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> IO (Chat 'CTGroup) +getGroupChatLast_ db user g contentFilter count search stats = do + ciIds <- getGroupChatItemIDs db user g contentFilter GRLast count search ts <- getCurrentTime cis <- mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g) (reverse cis) stats -getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId] -getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - ORDER BY item_ts DESC, chat_item_id DESC - LIMIT ? - |] - (userId, groupId, search, count) +data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId + +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] +getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range count search = case contentFilter of + Just mcTag -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) + Nothing -> idsQuery baseCond (userId, groupId) + where + baseQuery = " SELECT chat_item_id FROM chat_items WHERE " + baseCond = " user_id = ? AND group_id = ? " + idsQuery :: ToRow p => Query -> p -> IO [ChatItemId] + idsQuery c p = case range of + GRLast -> rangeQuery c p " ORDER BY item_ts DESC, chat_item_id DESC " + GRAfter ts itemId -> + rangeQuery + (" item_ts > ? " `orCond` " item_ts = ? AND chat_item_id > ? ") + (orParams ts itemId) + " ORDER BY item_ts ASC, chat_item_id ASC " + GRBefore ts itemId -> + rangeQuery + (" item_ts < ? " `orCond` " item_ts = ? AND chat_item_id < ? ") + (orParams ts itemId) + " ORDER BY item_ts DESC, chat_item_id DESC " + where + orCond c1 c2 = " (" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ") " + orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) + rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] + rangeQuery c p ob + | null search = searchQuery "" () + | otherwise = searchQuery " AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' " (Only search) + where + searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] + searchQuery c' p' = + map fromOnly <$> DB.query db (baseQuery <> c <> c' <> ob <> " LIMIT ?") (p :. p' :. Only count) safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup) safeGetGroupItem db user g currentTs itemId = @@ -1271,64 +1294,36 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user g@GroupInfo {groupId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId - ciIds <- liftIO $ getGroupCIsAfter_ db user g afterCI count search + let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) + ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) cis stats + pure $ Chat (GroupChat g) cis emptyChatStats -getGroupCIsAfter_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] -getGroupCIsAfter_ db User {userId} GroupInfo {groupId} afterCI count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) - ORDER BY item_ts ASC, chat_item_id ASC - LIMIT ? - |] - (userId, groupId, search, chatItemTs afterCI, chatItemTs afterCI, cChatItemId afterCI, count) - -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user g@GroupInfo {groupId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId - ciIds <- liftIO $ getGroupCIsBefore_ db user g beforeCI count search + let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) + ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) stats + pure $ Chat (GroupChat g) (reverse cis) emptyChatStats -getGroupCIsBefore_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] -getGroupCIsBefore_ db User {userId} GroupInfo {groupId} beforeCI count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) - ORDER BY item_ts DESC, chat_item_id DESC - LIMIT ? - |] - (userId, groupId, search, chatItemTs beforeCI, chatItemTs beforeCI, cChatItemId beforeCI, count) - -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround_ db user g aroundId count search = do +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ db user g contentFilter aroundId count search = do stats <- liftIO $ getGroupStats_ db user g - getGroupChatAround' db user g aroundId count search stats + getGroupChatAround' db user g contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround' db user g@GroupInfo {groupId} aroundId count search stats = do +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count search stats = do aroundCI <- getGroupChatItem db user groupId aroundId - beforeIds <- liftIO $ getGroupCIsBefore_ db user g aroundCI count search - afterIds <- liftIO $ getGroupCIsAfter_ db user g aroundCI count search + let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) + afterRange = GRAfter (chatItemTs aroundCI) (cChatItemId aroundCI) + beforeIds <- liftIO $ getGroupChatItemIDs db user g contentFilter beforeRange count search + afterIds <- liftIO $ getGroupChatItemIDs db user g contentFilter afterRange count search ts <- liftIO getCurrentTime beforeCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) beforeIds afterCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) afterIds @@ -1340,46 +1335,62 @@ getGroupChatAround' db user g@GroupInfo {groupId} aroundId count search stats = [] -> pure $ NavigationInfo 0 0 cis -> getGroupNavInfo_ db user g (last cis) -getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatInitial_ db user g count = - liftIO (getGroupMinUnreadId_ db user g) >>= \case +getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatInitial_ db user g contentFilter count = do + liftIO (getGroupMinUnreadId_ db user g contentFilter) >>= \case Just minUnreadItemId -> do - unreadCount <- liftIO $ getGroupUnreadCount_ db user g - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} - getGroupChatAround' db user g minUnreadItemId count "" stats - Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g count "" + stats <- liftIO $ getStats minUnreadItemId =<< getGroupUnreadCount_ db user g Nothing + getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats + Nothing -> liftIO $ do + stats <- getStats 0 0 + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats + where + getStats minUnreadItemId unreadCount = do + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats getGroupStats_ db user g = do - minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g - unreadCount <- getGroupUnreadCount_ db user g - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing + unreadCount <- getGroupUnreadCount_ db user g Nothing + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} -getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> IO (Maybe ChatItemId) -getGroupMinUnreadId_ db User {userId} GroupInfo {groupId} = +getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) +getGroupMinUnreadId_ db user g contentFilter = fmap join . maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? - ORDER BY item_ts ASC, chat_item_id ASC - LIMIT 1 - |] - (userId, groupId, CISRcvNew) + queryUnreadGroupItems db user g contentFilter baseQuery orderLimit + where + baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " + orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupUnreadCount_ db User {userId} GroupInfo {groupId} = +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO Int +getGroupUnreadCount_ db user g contentFilter = + fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" + where + baseQuery = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? " + +getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int +getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = fromOnly . head <$> DB.query db - [sql| - SELECT COUNT(1) - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? - |] - (userId, groupId, CISRcvNew) + "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" + (userId, groupId, MCReport_, BI archived) + +queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Query -> Query -> IO [r] +queryUnreadGroupItems db User {userId} GroupInfo {groupId} contentFilter baseQuery orderLimit = + case contentFilter of + Just mcTag -> + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, CISRcvNew) + Nothing -> + DB.query + db + (baseQuery <> " AND item_status = ? " <> orderLimit) + (userId, groupId, CISRcvNew) getGroupNavInfo_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO NavigationInfo getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do @@ -1390,52 +1401,47 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id AND item_status = :rcv_new - AND item_ts > :item_ts + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id AND item_status = :rcv_new - AND item_ts = :item_ts AND chat_item_id > :item_id - ) + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":group_id" := groupId, - ":rcv_new" := CISRcvNew, - ":item_ts" := chatItemTs afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, groupId, CISRcvNew, chatItemTs afterCI) + :. (userId, groupId, CISRcvNew, chatItemTs afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id - AND item_ts > :item_ts + WHERE user_id = ? AND group_id = ? + AND item_ts > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id - AND item_ts = :item_ts AND chat_item_id > :item_id - ) + WHERE user_id = ? AND group_id = ? + AND item_ts = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":group_id" := groupId, - ":item_ts" := chatItemTs afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, groupId, chatItemTs afterCI) + :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) + ) getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat db user folderId pagination search_ = do @@ -1452,11 +1458,10 @@ getLocalChat db user folderId pagination search_ = do getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ db user nf count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} ciIds <- getLocalChatItemIdsLast_ db user nf count search ts <- getCurrentTime cis <- mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) (reverse cis) stats + pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = @@ -1466,7 +1471,7 @@ getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY created_at DESC, chat_item_id DESC LIMIT ? |] @@ -1500,12 +1505,11 @@ safeToLocalItem currentTs itemId = \case getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterCI <- getLocalChatItem db user noteFolderId afterId ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) cis stats + pure $ Chat (LocalChat nf) cis emptyChatStats getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = @@ -1515,7 +1519,7 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) ORDER BY created_at ASC, chat_item_id ASC LIMIT ? @@ -1524,12 +1528,11 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeCI <- getLocalChatItem db user noteFolderId beforeId ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) (reverse cis) stats + pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = @@ -1539,7 +1542,7 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) ORDER BY created_at DESC, chat_item_id DESC LIMIT ? @@ -1572,7 +1575,7 @@ getLocalChatInitial_ db user nf count = do liftIO (getLocalMinUnreadId_ db user nf) >>= \case Just minUnreadItemId -> do unreadCount <- liftIO $ getLocalUnreadCount_ db user nf - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + let stats = emptyChatStats {unreadCount, minUnreadItemId} getLocalChatAround' db user nf minUnreadItemId count "" stats Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getLocalChatLast_ db user nf count "" @@ -1580,7 +1583,7 @@ getLocalStats_ :: DB.Connection -> User -> NoteFolder -> IO ChatStats getLocalStats_ db user nf = do minUnreadItemId <- fromMaybe 0 <$> getLocalMinUnreadId_ db user nf unreadCount <- getLocalUnreadCount_ db user nf - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + pure emptyChatStats {unreadCount, minUnreadItemId} getLocalMinUnreadId_ :: DB.Connection -> User -> NoteFolder -> IO (Maybe ChatItemId) getLocalMinUnreadId_ db User {userId} NoteFolder {noteFolderId} = @@ -1617,52 +1620,47 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id AND item_status = :rcv_new - AND created_at > :created_at + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id AND item_status = :rcv_new - AND created_at = :created_at AND chat_item_id > :item_id - ) + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":note_folder_id" := noteFolderId, - ":rcv_new" := CISRcvNew, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI) + :. (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id - AND created_at > :created_at + WHERE user_id = ? AND note_folder_id = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id - AND created_at = :created_at AND chat_item_id > :item_id - ) + WHERE user_id = ? AND note_folder_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci |] - [ ":user_id" := userId, - ":note_folder_id" := noteFolderId, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, noteFolderId, ciCreatedAt afterCI) + :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) + ) toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case @@ -1808,21 +1806,21 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) type ChatItemRow = - (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe Bool, Maybe SharedMsgId) - :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) + (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe BoolInt, Maybe SharedMsgId) + :. (Int, Maybe UTCTime, Maybe BoolInt, UTCTime, UTCTime) :. ChatItemForwardedFromRow :. ChatItemModeRow :. MaybeCIFIleRow -type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) +type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe BoolInt) toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) -toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent +toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> quotedSent where direction sent = if sent then CIQDirectSnd else CIQDirectRcv @@ -1863,9 +1861,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT let itemDeleted' = case itemDeleted of DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTDirect deletedTs) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1882,9 +1880,9 @@ type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ where - direction (Just True) _ = Just CIQGroupSnd - direction (Just False) (Just member) = Just . CIQGroupRcv $ Just member - direction (Just False) Nothing = Just $ CIQGroupRcv Nothing + direction (Just (BI True)) _ = Just CIQGroupSnd + direction (Just (BI False)) (Just member) = Just . CIQGroupRcv $ Just member + direction (Just (BI False)) Nothing = Just $ CIQGroupRcv Nothing direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json @@ -1925,9 +1923,9 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked deletedTs) DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1957,7 +1955,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? |] @@ -1968,7 +1966,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? @@ -1981,7 +1979,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? @@ -2037,7 +2035,7 @@ updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId i setDirectSndChatItemViaProxy :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect 'MDSnd -> Bool -> IO (ChatItem 'CTDirect 'MDSnd) setDirectSndChatItemViaProxy db User {userId} Contact {contactId} ci viaProxy = do - DB.execute db "UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (viaProxy, userId, contactId, chatItemId' ci) + DB.execute db "UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (BI viaProxy, userId, contactId, chatItemId' ci) pure ci {meta = (meta ci) {sentViaProxy = Just viaProxy}} updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) @@ -2089,7 +2087,7 @@ updateDirectChatItem_ db userId contactId ChatItem {meta, content} msgId_ = do SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, contactId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, contactId, itemId)) forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId updatedAt addInitialAndNewCIVersions :: DB.Connection -> ChatItemId -> (UTCTime, MsgContent) -> (UTCTime, MsgContent) -> IO () @@ -2114,7 +2112,7 @@ createChatItemVersion db itemId itemVersionTs msgContent = INSERT INTO chat_item_versions (chat_item_id, msg_content, item_version_ts) VALUES (?,?,?) |] - (itemId, toMCText msgContent, itemVersionTs) + (itemId, MCText $ msgContentText msgContent, itemVersionTs) deleteDirectChatItem :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> IO () deleteDirectChatItem db User {userId} Contact {contactId} ci = do @@ -2280,7 +2278,7 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () @@ -2388,6 +2386,20 @@ markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta (DBCIBlockedByAdmin, deletedTs, deletedTs, userId, groupId, chatItemId' ci) pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs, editable = False, deletable = False}} +markMessageReportsDeleted :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> GroupMember -> UTCTime -> IO [ChatItemId] +markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = CIMeta {itemSharedMsgId}} GroupMember {groupMemberId} deletedTs = do + currentTs <- liftIO getCurrentTime + map fromOnly + <$> DB.query + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + RETURNING chat_item_id; + |] + (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) + getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do itemId <- @@ -2604,7 +2616,7 @@ updateLocalChatItem_ db userId noteFolderId ChatItem {meta, content} = do SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, updatedAt) :. (userId, noteFolderId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, updatedAt) :. (userId, noteFolderId, itemId)) deleteLocalChatItem :: DB.Connection -> User -> NoteFolder -> ChatItem 'CTLocal d -> IO () deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do @@ -2771,8 +2783,8 @@ deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {ite "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?" (groupId, itemSharedMId, memberId) -toCIReaction :: (MsgReaction, Bool, Int) -> CIReactionCount -toCIReaction (reaction, userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} +toCIReaction :: (MsgReaction, BoolInt, Int) -> CIReactionCount +toCIReaction (reaction, BI userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} getDirectReactions :: DB.Connection -> Contact -> SharedMsgId -> Bool -> IO [MsgReaction] getDirectReactions db ct itemSharedMId sent = @@ -2784,7 +2796,7 @@ getDirectReactions db ct itemSharedMId sent = FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? |] - (contactId' ct, itemSharedMId, sent) + (contactId' ct, itemSharedMId, BI sent) setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs @@ -2796,7 +2808,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) VALUES (?,?,?,?,?,?) |] - (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) + (contactId' ct, itemSharedMId, BI sent, reaction, msgId, reactionTs) | otherwise = DB.execute db @@ -2804,7 +2816,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? |] - (contactId' ct, itemSharedMId, sent, reaction) + (contactId' ct, itemSharedMId, BI sent, reaction) getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = @@ -2816,7 +2828,7 @@ getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = FROM chat_item_reactions WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? |] - (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent) + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent) setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs @@ -2828,7 +2840,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) VALUES (?,?,?,?,?,?,?,?) |] - (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent, reaction, msgId, reactionTs) | otherwise = DB.execute db @@ -2836,7 +2848,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti DELETE FROM chat_item_reactions WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? |] - (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction) + (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) getReactionMembers :: DB.Connection -> VersionRangeChat -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] getReactionMembers db vr user groupId itemSharedMId reaction = do @@ -2874,11 +2886,12 @@ getTimedItems db User {userId} startTimedThreadCutoff = (itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt) _ -> Nothing -getChatItemTTL :: DB.Connection -> User -> IO (Maybe Int64) +getChatItemTTL :: DB.Connection -> User -> IO Int64 getChatItemTTL db User {userId} = - fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) + fmap (fromMaybe 0 . join) . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) -setChatItemTTL :: DB.Connection -> User -> Maybe Int64 -> IO () +setChatItemTTL :: DB.Connection -> User -> Int64 -> IO () setChatItemTTL db User {userId} chatItemTTL = do currentTs <- getCurrentTime r :: (Maybe Int64) <- maybeFirstRow fromOnly $ DB.query db "SELECT 1 FROM settings WHERE user_id = ? LIMIT 1" (Only userId) @@ -2894,6 +2907,14 @@ setChatItemTTL db User {userId} chatItemTTL = do "INSERT INTO settings (user_id, chat_item_ttl, created_at, updated_at) VALUES (?,?,?,?)" (userId, chatItemTTL, currentTs, currentTs) +getChatTTLCount :: DB.Connection -> User -> IO Int +getChatTTLCount db User {userId} = do + contactCount <- getCount "SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" + groupCount <- getCount "SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0" + pure $ contactCount + groupCount + where + getCount q = fromOnly . head <$> DB.query db q (Only userId) + getContactExpiredFileInfo :: DB.Connection -> User -> Contact -> UTCTime -> IO [CIFileInfo] getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate = map toFileInfo @@ -3005,7 +3026,7 @@ setGroupSndViaProxy db itemId memberId viaProxy = SET via_proxy = ? WHERE chat_item_id = ? AND group_member_id = ? |] - (viaProxy, itemId, memberId) + (BI viaProxy, itemId, memberId) getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [MemberDeliveryStatus] getGroupSndStatuses db itemId = @@ -3020,7 +3041,7 @@ getGroupSndStatuses db itemId = (Only itemId) where memStatus (groupMemberId, memberDeliveryStatus, sentViaProxy) = - MemberDeliveryStatus {groupMemberId, memberDeliveryStatus, sentViaProxy} + MemberDeliveryStatus {groupMemberId, memberDeliveryStatus, sentViaProxy = unBI <$> sentViaProxy} getGroupSndStatusCounts :: DB.Connection -> ChatItemId -> IO [(GroupSndStatus, Int)] getGroupSndStatusCounts db itemId = @@ -3051,9 +3072,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? WHERE i.user_id = ? AND i.group_id = ? AND i.item_content_tag IN (?,?) + AND i.msg_content_tag NOT IN (?) AND i.item_deleted = 0 AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) + (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) diff --git a/src/Simplex/Chat/Store/NoteFolders.hs b/src/Simplex/Chat/Store/NoteFolders.hs index e8336a73d8..8f71f3f21e 100644 --- a/src/Simplex/Chat/Store/NoteFolders.hs +++ b/src/Simplex/Chat/Store/NoteFolders.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -10,13 +11,19 @@ module Simplex.Chat.Store.NoteFolders where import Control.Monad.Except (ExceptT (..), throwError) import Control.Monad.IO.Class (liftIO) import Data.Time (getCurrentTime) -import Database.SQLite.Simple (Only (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types (NoteFolder (..), NoteFolderId, User (..)) import Simplex.Messaging.Agent.Protocol (UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif createNoteFolder :: DB.Connection -> User -> ExceptT StoreError IO () createNoteFolder db User {userId} = do @@ -43,13 +50,13 @@ getNoteFolder db User {userId} noteFolderId = |] (userId, noteFolderId) where - toNoteFolder (createdAt, updatedAt, chatTs, favorite, unread) = + toNoteFolder (createdAt, updatedAt, chatTs, BI favorite, BI unread) = NoteFolder {noteFolderId, userId, createdAt, updatedAt, chatTs, favorite, unread} updateNoteFolderUnreadChat :: DB.Connection -> User -> NoteFolder -> Bool -> IO () updateNoteFolderUnreadChat db User {userId} NoteFolder {noteFolderId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ?" (unreadChat, updatedAt, userId, noteFolderId) + DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ?" (BI unreadChat, updatedAt, userId, noteFolderId) deleteNoteFolderFiles :: DB.Connection -> UserId -> NoteFolder -> IO () deleteNoteFolderFiles db userId NoteFolder {noteFolderId} = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..285a952279 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Store.Postgres.Migrations (migrations) where + +import Data.List (sortOn) +import Data.Text (Text) +import Simplex.Chat.Store.Postgres.Migrations.M20241220_initial +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20241220_initial", m20241220_initial, Nothing) + ] + +-- | The list of migrations in ascending order by date +migrations :: [Migration] +migrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs new file mode 100644 index 0000000000..e8fd77aa0d --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -0,0 +1,1015 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20241220_initial where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20241220_initial :: Text +m20241220_initial = + T.pack + [r| +CREATE TABLE users( + user_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT NOT NULL UNIQUE, + local_display_name TEXT NOT NULL UNIQUE, + active_user SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + agent_user_id BIGINT NOT NULL, + view_pwd_hash BYTEA, + view_pwd_salt BYTEA, + show_ntfs SMALLINT NOT NULL DEFAULT 1, + send_rcpts_contacts SMALLINT NOT NULL DEFAULT 0, + send_rcpts_small_groups SMALLINT NOT NULL DEFAULT 0, + user_member_profile_updated_at TIMESTAMPTZ, + ui_themes TEXT, + active_order BIGINT NOT NULL DEFAULT 0 +); +CREATE TABLE contact_profiles( + contact_profile_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + image TEXT, + user_id BIGINT DEFAULT NULL REFERENCES users ON DELETE CASCADE, + incognito SMALLINT, + local_alias TEXT NOT NULL DEFAULT '', + preferences TEXT, + contact_link BYTEA +); +CREATE TABLE display_names( + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + ldn_base TEXT NOT NULL, + ldn_suffix BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY(user_id, local_display_name), + UNIQUE(user_id, ldn_base, ldn_suffix) +); +ALTER TABLE users +ADD CONSTRAINT fk_users_display_names + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE RESTRICT + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE contacts( + contact_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + is_user SMALLINT NOT NULL DEFAULT 0, + via_group BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL, + xcontact_id BYTEA, + enable_ntfs SMALLINT, + unread_chat SMALLINT NOT NULL DEFAULT 0, + contact_used SMALLINT NOT NULL DEFAULT 0, + user_preferences TEXT NOT NULL DEFAULT '{}', + chat_ts TIMESTAMPTZ, + deleted SMALLINT NOT NULL DEFAULT 0, + favorite SMALLINT NOT NULL DEFAULT 0, + send_rcpts SMALLINT, + contact_group_member_id BIGINT, + contact_grp_inv_sent SMALLINT NOT NULL DEFAULT 0, + contact_status TEXT NOT NULL DEFAULT 'active', + custom_data BYTEA, + ui_themes TEXT, + chat_deleted SMALLINT NOT NULL DEFAULT 0, + chat_item_ttl BIGINT, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, contact_profile_id) +); +ALTER TABLE users +ADD CONSTRAINT fk_users_contacts + FOREIGN KEY(contact_id) + REFERENCES contacts(contact_id) + ON DELETE RESTRICT + DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE known_servers( + server_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BYTEA, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, host, port) +); +CREATE TABLE group_profiles( + group_profile_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + image TEXT, + user_id BIGINT DEFAULT NULL REFERENCES users ON DELETE CASCADE, + preferences TEXT, + description TEXT NULL +); +CREATE TABLE groups( + group_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + group_profile_id BIGINT REFERENCES group_profiles ON DELETE SET NULL, + inv_queue_info BYTEA, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + chat_item_id BIGINT DEFAULT NULL, + enable_ntfs SMALLINT, + host_conn_custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + unread_chat SMALLINT NOT NULL DEFAULT 0, + chat_ts TIMESTAMPTZ, + favorite SMALLINT NOT NULL DEFAULT 0, + send_rcpts SMALLINT, + via_group_link_uri_hash BYTEA, + user_member_profile_sent_at TIMESTAMPTZ, + custom_data BYTEA, + ui_themes TEXT, + business_member_id BYTEA NULL, + business_chat TEXT NULL, + business_xcontact_id BYTEA NULL, + customer_member_id BYTEA NULL, + chat_item_ttl BIGINT, + local_alias TEXT DEFAULT '', + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, group_profile_id) +); +ALTER TABLE contacts +ADD CONSTRAINT fk_contacts_groups + FOREIGN KEY(via_group) + REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE TABLE group_members( + group_member_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BYTEA NOT NULL, + member_role TEXT NOT NULL, + member_category TEXT NOT NULL, + member_status TEXT NOT NULL, + invited_by BIGINT REFERENCES contacts(contact_id) ON DELETE SET NULL, + sent_inv_queue_info BYTEA, + group_queue_info BYTEA, + direct_queue_info BYTEA, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + contact_profile_id BIGINT NOT NULL REFERENCES contact_profiles ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + member_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + show_messages SMALLINT NOT NULL DEFAULT 1, + xgrplinkmem_received SMALLINT NOT NULL DEFAULT 0, + invited_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + member_restriction TEXT, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(group_id, member_id) +); +ALTER TABLE contacts +ADD CONSTRAINT fk_contacts_group_members + FOREIGN KEY(contact_group_member_id) + REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE TABLE group_member_intros( + group_member_intro_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + re_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + to_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + group_queue_info BYTEA, + direct_queue_info BYTEA, + intro_status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3, + UNIQUE(re_group_member_id, to_group_member_id) +); +CREATE TABLE files( + file_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT, + file_size BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + chat_item_id BIGINT DEFAULT NULL, + updated_at TIMESTAMPTZ NOT NULL, + cancelled SMALLINT, + ci_file_status TEXT, + file_inline TEXT, + agent_snd_file_id BYTEA NULL, + private_snd_file_descr TEXT NULL, + agent_snd_file_deleted SMALLINT NOT NULL DEFAULT 0, + protocol TEXT NOT NULL DEFAULT 'smp', + file_crypto_key BYTEA, + file_crypto_nonce BYTEA, + note_folder_id BIGINT DEFAULT NULL, + redirect_file_id BIGINT REFERENCES files ON DELETE CASCADE +); +CREATE TABLE snd_files( + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + connection_id BIGINT NOT NULL, + file_status TEXT NOT NULL, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + file_inline TEXT, + last_inline_msg_delivery_id BIGINT, + file_descr_id BIGINT NULL, + PRIMARY KEY(file_id, connection_id) +); +CREATE TABLE rcv_files( + file_id BIGINT PRIMARY KEY REFERENCES files ON DELETE CASCADE, + file_status TEXT NOT NULL, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + file_queue_info BYTEA, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + rcv_file_inline TEXT, + file_inline TEXT, + file_descr_id BIGINT NULL, + agent_rcv_file_id BYTEA NULL, + agent_rcv_file_deleted SMALLINT NOT NULL DEFAULT 0, + to_receive SMALLINT, + user_approved_relays SMALLINT NOT NULL DEFAULT 0 +); +CREATE TABLE snd_file_chunks( + file_id BIGINT NOT NULL, + connection_id BIGINT NOT NULL, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT, + chunk_sent SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +); +CREATE TABLE rcv_file_chunks( + file_id BIGINT NOT NULL REFERENCES rcv_files ON DELETE CASCADE, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT NOT NULL, + chunk_stored SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY(file_id, chunk_number) +); +CREATE TABLE connections( + connection_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + agent_conn_id BYTEA NOT NULL UNIQUE, + conn_level BIGINT NOT NULL DEFAULT 0, + via_contact BIGINT REFERENCES contacts(contact_id) ON DELETE SET NULL, + conn_status TEXT NOT NULL, + conn_type TEXT NOT NULL, + user_contact_link_id BIGINT, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + snd_file_id BIGINT, + rcv_file_id BIGINT REFERENCES rcv_files(file_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + via_contact_uri_hash BYTEA, + xcontact_id BYTEA, + via_user_contact_link BIGINT DEFAULT NULL, + custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + conn_req_inv BYTEA, + local_alias TEXT NOT NULL DEFAULT '', + via_group_link SMALLINT NOT NULL DEFAULT 0, + group_link_id BYTEA, + security_code TEXT NULL, + security_code_verified_at TIMESTAMPTZ NULL, + auth_err_counter BIGINT NOT NULL DEFAULT 0, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + to_subscribe SMALLINT DEFAULT 0 NOT NULL, + contact_conn_initiated SMALLINT NOT NULL DEFAULT 0, + conn_chat_version INTEGER, + pq_support SMALLINT NOT NULL DEFAULT 0, + pq_encryption SMALLINT NOT NULL DEFAULT 0, + pq_snd_enabled SMALLINT, + pq_rcv_enabled SMALLINT, + quota_err_counter BIGINT NOT NULL DEFAULT 0, + FOREIGN KEY(snd_file_id, connection_id) + REFERENCES snd_files(file_id, connection_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +ALTER TABLE snd_files +ADD CONSTRAINT fk_snd_files_connections + FOREIGN KEY(connection_id) + REFERENCES connections(connection_id) ON DELETE CASCADE; +CREATE TABLE user_contact_links( + user_contact_link_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + conn_req_contact BYTEA NOT NULL, + local_display_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + auto_accept SMALLINT DEFAULT 0, + auto_reply_msg_content TEXT DEFAULT NULL, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + auto_accept_incognito SMALLINT NOT NULL DEFAULT 0, + group_link_id BYTEA, + group_link_member_role TEXT NULL, + business_address SMALLINT DEFAULT 0, + UNIQUE(user_id, local_display_name) +); +ALTER TABLE connections +ADD CONSTRAINT fk_connections_user_contact_links_user_contact_link_id + FOREIGN KEY(user_contact_link_id) + REFERENCES user_contact_links(user_contact_link_id) ON DELETE CASCADE; +ALTER TABLE connections +ADD CONSTRAINT fk_connections_user_contact_links_via_user_contact_link + FOREIGN KEY(via_user_contact_link) + REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL; +CREATE TABLE contact_requests( + contact_request_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_contact_link_id BIGINT NOT NULL REFERENCES user_contact_links + ON UPDATE CASCADE ON DELETE CASCADE, + agent_invitation_id BYTEA NOT NULL, + contact_profile_id BIGINT REFERENCES contact_profiles + ON DELETE SET NULL + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + xcontact_id BYTEA, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + pq_support SMALLINT NOT NULL DEFAULT 0, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON UPDATE CASCADE + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, contact_profile_id) +); +CREATE TABLE messages( + message_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + msg_sent SMALLINT NOT NULL, + chat_msg_event TEXT NOT NULL, + msg_body BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL, + connection_id BIGINT DEFAULT NULL REFERENCES connections ON DELETE CASCADE, + group_id BIGINT DEFAULT NULL REFERENCES groups ON DELETE CASCADE, + shared_msg_id BYTEA, + shared_msg_id_user SMALLINT, + author_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + forwarded_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL +); +CREATE TABLE pending_group_messages( + pending_group_message_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + message_id BIGINT NOT NULL REFERENCES messages ON DELETE CASCADE, + group_member_intro_id BIGINT REFERENCES group_member_intros ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_items( + chat_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + chat_msg_id BIGINT, + created_by_msg_id BIGINT UNIQUE REFERENCES messages(message_id) ON DELETE SET NULL, + item_sent SMALLINT NOT NULL, + item_ts TIMESTAMPTZ NOT NULL, + item_deleted SMALLINT NOT NULL DEFAULT 0, + item_content TEXT NOT NULL, + item_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + item_status TEXT NOT NULL, + shared_msg_id BYTEA, + quoted_shared_msg_id BYTEA, + quoted_sent_at TIMESTAMPTZ, + quoted_content TEXT, + quoted_sent SMALLINT, + quoted_member_id BYTEA, + item_edited SMALLINT, + timed_ttl BIGINT, + timed_delete_at TIMESTAMPTZ, + item_live SMALLINT, + item_deleted_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + item_deleted_ts TIMESTAMPTZ, + forwarded_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + item_content_tag TEXT, + note_folder_id BIGINT DEFAULT NULL, + fwd_from_tag TEXT, + fwd_from_chat_name TEXT, + fwd_from_msg_dir SMALLINT, + fwd_from_contact_id BIGINT REFERENCES contacts ON DELETE SET NULL, + fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL, + fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, + via_proxy SMALLINT, + msg_content_tag TEXT +); +ALTER TABLE groups +ADD CONSTRAINT fk_groups_chat_items + FOREIGN KEY(chat_item_id) + REFERENCES chat_items(chat_item_id) ON DELETE SET NULL; +ALTER TABLE files +ADD CONSTRAINT fk_files_chat_items + FOREIGN KEY(chat_item_id) + REFERENCES chat_items(chat_item_id) ON DELETE CASCADE; +CREATE TABLE chat_item_messages( + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + message_id BIGINT NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + UNIQUE(chat_item_id, message_id) +); +CREATE TABLE calls( + call_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT NOT NULL REFERENCES contacts ON DELETE CASCADE, + shared_call_id BYTEA NOT NULL, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + call_state BYTEA NOT NULL, + call_ts TIMESTAMPTZ NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + call_uuid TEXT NOT NULL DEFAULT '' +); +CREATE TABLE commands( + command_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + connection_id BIGINT REFERENCES connections ON DELETE CASCADE, + command_function TEXT NOT NULL, + command_status TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE settings( + settings_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_ttl BIGINT, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE protocol_servers( + smp_server_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BYTEA NOT NULL, + basic_auth TEXT, + preset SMALLINT NOT NULL DEFAULT 0, + tested SMALLINT, + enabled SMALLINT NOT NULL DEFAULT 1, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + protocol TEXT NOT NULL DEFAULT 'smp', + UNIQUE(user_id, host, port) +); +CREATE TABLE xftp_file_descriptions( + file_descr_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + file_descr_text TEXT NOT NULL, + file_descr_part_no BIGINT NOT NULL DEFAULT(0), + file_descr_complete SMALLINT NOT NULL DEFAULT(0), + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +ALTER TABLE snd_files +ADD CONSTRAINT fk_snd_files_xftp_file_descriptions + FOREIGN KEY(file_descr_id) + REFERENCES xftp_file_descriptions(file_descr_id) ON DELETE SET NULL; +ALTER TABLE rcv_files +ADD CONSTRAINT fk_rcv_files_xftp_file_descriptions + FOREIGN KEY(file_descr_id) + REFERENCES xftp_file_descriptions(file_descr_id) ON DELETE SET NULL; +CREATE TABLE extra_xftp_file_descriptions( + extra_file_descr_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + file_descr_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_versions( + chat_item_version_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + msg_content TEXT NOT NULL, + item_version_ts TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_reactions( + chat_item_reaction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + item_member_id BYTEA, + shared_msg_id BYTEA NOT NULL, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + created_by_msg_id BIGINT REFERENCES messages(message_id) ON DELETE SET NULL, + reaction TEXT NOT NULL, + reaction_sent SMALLINT NOT NULL, + reaction_ts TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_moderations( + chat_item_moderation_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + moderator_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + item_member_id BYTEA NOT NULL, + shared_msg_id BYTEA NOT NULL, + created_by_msg_id BIGINT REFERENCES messages(message_id) ON DELETE SET NULL, + moderated_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE group_snd_item_statuses( + group_snd_item_status_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + group_snd_item_status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + via_proxy SMALLINT +); +CREATE TABLE sent_probes( + sent_probe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + probe BYTEA NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, probe) +); +CREATE TABLE sent_probe_hashes( + sent_probe_hash_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + sent_probe_id BIGINT NOT NULL REFERENCES sent_probes ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE received_probes( + received_probe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + probe BYTEA, + probe_hash BYTEA NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE remote_hosts( + remote_host_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_key BYTEA NOT NULL, + ca_cert BYTEA NOT NULL, + id_key BYTEA NOT NULL, + host_fingerprint BYTEA NOT NULL, + host_dh_pub BYTEA NOT NULL, + bind_addr TEXT, + bind_iface TEXT, + bind_port INTEGER +); +CREATE TABLE remote_controllers( + remote_ctrl_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + ctrl_device_name TEXT NOT NULL, + ca_key BYTEA NOT NULL, + ca_cert BYTEA NOT NULL, + ctrl_fingerprint BYTEA NOT NULL, + id_pub BYTEA NOT NULL, + dh_priv_key BYTEA NOT NULL, + prev_dh_priv_key BYTEA +); +CREATE TABLE msg_deliveries( + msg_delivery_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + message_id BIGINT NOT NULL REFERENCES messages ON DELETE CASCADE, + connection_id BIGINT NOT NULL REFERENCES connections ON DELETE CASCADE, + agent_msg_id BIGINT, + agent_msg_meta TEXT, + chat_ts TIMESTAMPTZ NOT NULL DEFAULT (now()), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + delivery_status TEXT +); +CREATE TABLE note_folders( + note_folder_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + chat_ts TIMESTAMPTZ NOT NULL DEFAULT (now()), + favorite SMALLINT NOT NULL DEFAULT 0, + unread_chat SMALLINT NOT NULL DEFAULT 0 +); +ALTER TABLE files +ADD CONSTRAINT fk_files_note_folders + FOREIGN KEY(note_folder_id) + REFERENCES note_folders(note_folder_id) ON DELETE CASCADE; +ALTER TABLE chat_items +ADD CONSTRAINT fk_chat_items_note_folders + FOREIGN KEY(note_folder_id) + REFERENCES note_folders(note_folder_id) ON DELETE CASCADE; +CREATE TABLE app_settings(app_settings TEXT NOT NULL); +CREATE TABLE server_operators( + server_operator_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled SMALLINT NOT NULL DEFAULT 1, + smp_role_storage SMALLINT NOT NULL DEFAULT 1, + smp_role_proxy SMALLINT NOT NULL DEFAULT 1, + xftp_role_storage SMALLINT NOT NULL DEFAULT 1, + xftp_role_proxy SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE usage_conditions( + usage_conditions_id BIGINT PRIMARY KEY, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE operator_usage_conditions( + operator_usage_conditions_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + server_operator_id BIGINT REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + auto_accepted SMALLINT DEFAULT 0 +); +CREATE TABLE chat_tags( + chat_tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order BIGINT NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + chat_tag_id BIGINT NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); +CREATE INDEX contact_profiles_index ON contact_profiles( + display_name, + full_name +); +CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); +CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); +CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); +CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( + user_id, + contact_id, + shared_msg_id +); +CREATE UNIQUE INDEX idx_chat_items_group_shared_msg_id ON chat_items( + user_id, + group_id, + group_member_id, + shared_msg_id +); +CREATE UNIQUE INDEX idx_user_contact_links_group_id ON user_contact_links( + group_id +); +CREATE UNIQUE INDEX idx_snd_files_last_inline_msg_delivery_id ON snd_files( + last_inline_msg_delivery_id +); +CREATE INDEX idx_messages_connection_id ON messages(connection_id); +CREATE INDEX idx_chat_items_group_member_id ON chat_items(group_member_id); +CREATE INDEX idx_chat_items_contact_id ON chat_items(contact_id); +CREATE INDEX idx_chat_items_item_status ON chat_items(item_status); +CREATE INDEX idx_connections_group_member ON connections( + user_id, + group_member_id +); +CREATE INDEX idx_commands_connection_id ON commands(connection_id); +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections( + custom_user_profile_id +); +CREATE INDEX idx_connections_via_user_contact_link ON connections( + via_user_contact_link +); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections( + user_contact_link_id +); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( + contact_profile_id +); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( + user_contact_link_id +); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros( + to_group_member_id +); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members( + user_id, + local_display_name +); +CREATE INDEX idx_group_members_member_profile_id ON group_members( + member_profile_id +); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members( + contact_profile_id +); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( + host_conn_custom_user_profile_id +); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages( + group_member_intro_id +); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages( + message_id +); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages( + group_member_id +); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( + file_id, + connection_id +); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +CREATE INDEX idx_smp_servers_user_id ON protocol_servers(user_id); +CREATE INDEX idx_chat_items_item_deleted_by_group_member_id ON chat_items( + item_deleted_by_group_member_id +); +CREATE INDEX idx_snd_files_file_descr_id ON snd_files(file_descr_id); +CREATE INDEX idx_rcv_files_file_descr_id ON rcv_files(file_descr_id); +CREATE INDEX idx_extra_xftp_file_descriptions_file_id ON extra_xftp_file_descriptions( + file_id +); +CREATE INDEX idx_extra_xftp_file_descriptions_user_id ON extra_xftp_file_descriptions( + user_id +); +CREATE INDEX idx_xftp_file_descriptions_user_id ON xftp_file_descriptions( + user_id +); +CREATE INDEX idx_chat_item_versions_chat_item_id ON chat_item_versions( + chat_item_id +); +CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions( + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions( + contact_id +); +CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id); +CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions( + group_member_id +); +CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions( + contact_id, + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions( + group_id, + shared_msg_id +); +CREATE INDEX idx_messages_created_at ON messages(created_at); +CREATE INDEX idx_chat_item_reactions_created_by_msg_id ON chat_item_reactions( + created_by_msg_id +); +CREATE INDEX idx_chat_items_timed_delete_at ON chat_items( + user_id, + timed_delete_at +); +CREATE INDEX idx_group_members_group_id ON group_members(user_id, group_id); +CREATE INDEX idx_chat_item_moderations_group_id ON chat_item_moderations( + group_id +); +CREATE INDEX idx_chat_item_moderations_moderator_member_id ON chat_item_moderations( + moderator_member_id +); +CREATE INDEX idx_chat_item_moderations_created_by_msg_id ON chat_item_moderations( + created_by_msg_id +); +CREATE INDEX idx_chat_item_moderations_group ON chat_item_moderations( + group_id, + item_member_id, + shared_msg_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses( + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses( + group_member_id +); +CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( + user_id, + item_status +); +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); +CREATE INDEX idx_contacts_contact_group_member_id ON contacts( + contact_group_member_id +); +CREATE INDEX idx_sent_probes_user_id ON sent_probes(user_id); +CREATE INDEX idx_sent_probes_contact_id ON sent_probes(contact_id); +CREATE INDEX idx_sent_probes_group_member_id ON sent_probes(group_member_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_sent_probe_id ON sent_probe_hashes( + sent_probe_id +); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_sent_probe_hashes_group_member_id ON sent_probe_hashes( + group_member_id +); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_received_probes_probe ON received_probes(probe); +CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +CREATE INDEX idx_connections_conn_req_inv ON connections( + user_id, + conn_req_inv +); +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + user_id, + via_group_link_uri_hash +); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + user_id, + via_contact_uri_hash +); +CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles( + user_id, + contact_link +); +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros( + re_group_member_id +); +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members( + invited_by_group_member_id +); +CREATE INDEX idx_messages_author_group_member_id ON messages( + author_group_member_id +); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages( + forwarded_by_group_member_id +); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( + group_id, + shared_msg_id +); +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( + forwarded_by_group_member_id +); +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( + host_fingerprint +); +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( + ctrl_fingerprint +); +CREATE INDEX idx_contacts_chat_ts ON contacts(user_id, chat_ts); +CREATE INDEX idx_groups_chat_ts ON groups(user_id, chat_ts); +CREATE INDEX idx_contact_requests_updated_at ON contact_requests( + user_id, + updated_at +); +CREATE INDEX idx_connections_updated_at ON connections(user_id, updated_at); +CREATE INDEX idx_msg_deliveries_message_id ON msg_deliveries(message_id); +CREATE INDEX idx_msg_deliveries_agent_msg_id ON msg_deliveries( + connection_id, + agent_msg_id +); +CREATE INDEX chat_items_note_folder_id ON chat_items(note_folder_id); +CREATE INDEX files_note_folder_id ON files(note_folder_id); +CREATE INDEX note_folders_user_id ON note_folders(user_id); +CREATE INDEX idx_chat_items_contacts_created_at on chat_items( + user_id, + contact_id, + created_at +); +CREATE INDEX idx_chat_items_notes_created_at on chat_items( + user_id, + note_folder_id, + created_at +); +CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id); +CREATE INDEX idx_chat_items_fwd_from_contact_id ON chat_items( + fwd_from_contact_id +); +CREATE INDEX idx_chat_items_fwd_from_group_id ON chat_items(fwd_from_group_id); +CREATE INDEX idx_chat_items_fwd_from_chat_item_id ON chat_items( + fwd_from_chat_item_id +); +CREATE INDEX idx_received_probes_group_member_id on received_probes( + group_member_id +); +CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id); +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions( + server_operator_id +); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions( + conditions_commit, + server_operator_id +); +CREATE INDEX idx_chat_items_contacts ON chat_items( + user_id, + contact_id, + item_status, + created_at +); +CREATE INDEX idx_chat_items_groups ON chat_items( + user_id, + group_id, + item_status, + item_ts +); +CREATE INDEX idx_chat_items_groups_item_ts ON chat_items( + user_id, + group_id, + item_ts +); +CREATE INDEX idx_chat_items_notes ON chat_items( + user_id, + note_folder_id, + item_status, + created_at +); +CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_ts +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_sent +); +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e88cf39feb..4fca5fb1a4 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -86,8 +87,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -100,8 +99,9 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String @@ -109,6 +109,13 @@ import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime @@ -124,7 +131,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?)" - (auId, displayName, activeUser, order, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, currentTs, currentTs) + (auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -138,10 +145,10 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" - (profileId, displayName, userId, True, currentTs, currentTs, currentTs) + (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo @@ -253,7 +260,7 @@ updateUserPrivacy db User {userId, showNtfs, viewPwdHash} = SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? WHERE user_id = ? |] - (hashSalt viewPwdHash :. (showNtfs, userId)) + (hashSalt viewPwdHash :. (BI showNtfs, userId)) where hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt)) @@ -262,16 +269,16 @@ updateAllContactReceipts db onOff = DB.execute db "UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL" - (onOff, onOff) + (BI onOff, BI onOff) updateUserContactReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () updateUserContactReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do - DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (enable, userId) + DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (BI enable, userId) when clearOverrides $ DB.execute_ db "UPDATE contacts SET send_rcpts = NULL" updateUserGroupReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do - DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (enable, userId) + DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (BI enable, userId) when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL" updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User @@ -403,21 +410,21 @@ deleteUserAddress db user@User {userId} = do ) |] (Only userId) - DB.executeNamed + DB.execute db [sql| DELETE FROM display_names - WHERE user_id = :user_id + WHERE user_id = ? AND local_display_name in ( SELECT cr.local_display_name FROM contact_requests cr JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL ) - AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - [":user_id" := userId] - DB.executeNamed + (userId, userId, userId) + DB.execute db [sql| DELETE FROM contact_profiles @@ -425,10 +432,10 @@ deleteUserAddress db user@User {userId} = do SELECT cr.contact_profile_id FROM contact_requests cr JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL ) |] - [":user_id" := userId] + (Only userId) void $ setUserProfileContactLink db user Nothing DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL" (Only userId) @@ -455,8 +462,8 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, Bool, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, autoAccept, businessAddress, acceptIncognito, autoReply) = +toUserContactLink :: (ConnReqContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing @@ -528,8 +535,8 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do |] (ucl :. Only userId) ucl = case autoAccept of - Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> (True, businessAddress, acceptIncognito, autoReply) - _ -> (False, False, False, Nothing) + Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> (BI True, BI businessAddress, BI acceptIncognito, autoReply) + _ -> (BI False, BI False, BI False, Nothing) getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] getProtocolServers db p User {userId} = @@ -543,10 +550,10 @@ getProtocolServers db p User {userId} = |] (userId, decodeLatin1 $ strEncode p) where - toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p - toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) = + toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p + toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) = let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in UserServer {serverId, server, preset, tested, enabled, deleted = False} + in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False} insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do @@ -557,7 +564,7 @@ insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, teste (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (serverColumns p server :. (preset, tested, enabled, userId, ts, ts)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, userId, ts, ts)) sId <- insertedRowId db pure (srv :: NewUserServer p) {serverId = DBEntityId sId} @@ -571,7 +578,7 @@ updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabl preset = ?, tested = ?, enabled = ?, updated_at = ? WHERE smp_server_id = ? |] - (serverColumns p server :. (preset, tested, enabled, ts, serverId)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId)) serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = @@ -611,29 +618,24 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? WHERE server_operator_id = ? |] - (enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId) + (BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId) getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)] getUpdateServerOperators db presetOps newUser = do conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery now <- getCurrentTime - let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + let (currentConds, condsToAdd) = usageConditionsToAdd newUser now conds mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe case operatorId op of - DBNewEntity -> do - op' <- insertOperator op - case (operatorTag op', acceptForSimplex_) of - (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond - _ -> pure op' + DBNewEntity -> insertOperator op DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case - CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds - CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds + CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} = @@ -654,7 +656,7 @@ getUpdateServerOperators db presetOps newUser = do SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ? WHERE server_operator_id = ? |] - (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId) + (tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), operatorId) insertOperator :: NewServerOperator -> IO ServerOperator insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do DB.execute @@ -664,12 +666,12 @@ getUpdateServerOperators db presetOps newUser = do (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) VALUES (?,?,?,?,?,?,?,?,?) |] - (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles) + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles)) opId <- insertedRowId db pure op {operatorId = DBEntityId opId} - autoAcceptConditions op UsageConditions {conditionsCommit} = - acceptConditions_ db op conditionsCommit Nothing - $> op {conditionsAcceptance = CAAccepted Nothing} + autoAcceptConditions op UsageConditions {conditionsCommit} now = + acceptConditions_ db op conditionsCommit now True + $> op {conditionsAcceptance = CAAccepted (Just now) True} serverOperatorQuery :: Query serverOperatorQuery = @@ -682,8 +684,8 @@ serverOperatorQuery = getServerOperators_ :: DB.Connection -> IO [ServerOperator] getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery -toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator -toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') = +toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, BoolInt) :. (BoolInt, BoolInt) :. (BoolInt, BoolInt) -> ServerOperator +toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, BI enabled) :. smpRoles' :. xftpRoles') = ServerOperator { operatorId, operatorTag, @@ -696,7 +698,7 @@ toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabl xftpRoles = serverRoles xftpRoles' } where - serverRoles (storage, proxy) = ServerRoles {storage, proxy} + serverRoles (BI storage, BI proxy) = ServerRoles {storage, proxy} getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do @@ -708,7 +710,7 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition DB.query db [sql| - SELECT conditions_commit, accepted_at + SELECT conditions_commit, accepted_at, auto_accepted FROM operator_usage_conditions WHERE server_operator_id = ? ORDER BY operator_usage_conditions_id DESC @@ -716,10 +718,10 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition |] (Only operatorId) pure $ case operatorAcceptedConds_ of - Just (operatorCommit, acceptedAt_) + Just (operatorCommit, acceptedAt_, BI autoAccept) | operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled? | currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) - | otherwise -> CAAccepted acceptedAt_ + | otherwise -> CAAccepted acceptedAt_ autoAccept _ -> CARequired Nothing -- no conditions were accepted for this operator getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions @@ -763,24 +765,39 @@ acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> Excep acceptConditions db condId opIds acceptedAt = do UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId operators <- mapM getServerOperator_ opIds - let ts = Just acceptedAt - liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts + liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit acceptedAt False where getServerOperator_ opId = ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) -acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () -acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = - DB.execute - db - [sql| - INSERT INTO operator_usage_conditions - (server_operator_id, server_operator_tag, conditions_commit, accepted_at) - VALUES (?,?,?,?) - |] - (operatorId, operatorTag, conditionsCommit, acceptedAt) +acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> UTCTime -> Bool -> IO () +acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt autoAccepted = do + acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ?" (operatorId, conditionsCommit) + case acceptedAt_ of + Just Nothing -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?") + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted, acceptedAt, BI autoAccepted) + Just (Just _) -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING") + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted) + Nothing -> + DB.execute + db + q + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted) + where + q = + [sql| + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted) + VALUES (?,?,?,?,?) + |] getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions getUsageConditionsById_ db conditionsId = @@ -810,7 +827,7 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | deleted -> pure Nothing | otherwise -> Just <$> insertProtocolServer db p user ts s DBEntityId srvId - | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) + | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False) | otherwise -> Just s <$ updateProtocolServer db p ts s createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index a88d87a04e..4921369b10 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -8,19 +9,23 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8, decodeASCII) +import Data.Text.Encoding (decodeASCII, encodeUtf8) import Data.Word (Word16) -import Database.SQLite.Simple (Only (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.RemoteControl.Types import UnliftIO +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query) +import Database.SQLite.Simple.QQ (sql) +#endif insertRemoteHost :: DB.Connection -> Text -> FilePath -> Maybe RCCtrlAddress -> Maybe Word16 -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath rcAddr_ bindPort_ RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do @@ -54,7 +59,7 @@ getRemoteHostByFingerprint db fingerprint = maybeFirstRow toRemoteHost $ DB.query db (remoteHostQuery <> " WHERE host_fingerprint = ?") (Only fingerprint) -remoteHostQuery :: SQL.Query +remoteHostQuery :: Query remoteHostQuery = [sql| SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub, bind_iface, bind_addr, bind_port @@ -117,7 +122,7 @@ getRemoteCtrlByFingerprint db fingerprint = maybeFirstRow toRemoteCtrl $ DB.query db (remoteCtrlQuery <> " WHERE ctrl_fingerprint = ?") (Only fingerprint) -remoteCtrlQuery :: SQL.Query +remoteCtrlQuery :: Query remoteCtrlQuery = [sql| SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs similarity index 54% rename from src/Simplex/Chat/Store/Migrations.hs rename to src/Simplex/Chat/Store/SQLite/Migrations.hs index aaf7ecdca1..f8bdc0d788 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -1,126 +1,130 @@ {-# LANGUAGE NamedFieldPuns #-} -module Simplex.Chat.Store.Migrations (migrations) where +module Simplex.Chat.Store.SQLite.Migrations (migrations) where import Data.List (sortOn) import Database.SQLite.Simple (Query (..)) -import Simplex.Chat.Migrations.M20220101_initial -import Simplex.Chat.Migrations.M20220122_v1_1 -import Simplex.Chat.Migrations.M20220205_chat_item_status -import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests -import Simplex.Chat.Migrations.M20220224_messages_fks -import Simplex.Chat.Migrations.M20220301_smp_servers -import Simplex.Chat.Migrations.M20220302_profile_images -import Simplex.Chat.Migrations.M20220304_msg_quotes -import Simplex.Chat.Migrations.M20220321_chat_item_edited -import Simplex.Chat.Migrations.M20220404_files_status_fields -import Simplex.Chat.Migrations.M20220514_profiles_user_id -import Simplex.Chat.Migrations.M20220626_auto_reply -import Simplex.Chat.Migrations.M20220702_calls -import Simplex.Chat.Migrations.M20220715_groups_chat_item_id -import Simplex.Chat.Migrations.M20220811_chat_items_indices -import Simplex.Chat.Migrations.M20220812_incognito_profiles -import Simplex.Chat.Migrations.M20220818_chat_notifications -import Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id -import Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items -import Simplex.Chat.Migrations.M20220824_profiles_local_alias -import Simplex.Chat.Migrations.M20220909_commands -import Simplex.Chat.Migrations.M20220926_connection_alias -import Simplex.Chat.Migrations.M20220928_settings -import Simplex.Chat.Migrations.M20221001_shared_msg_id_indices -import Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items -import Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id -import Simplex.Chat.Migrations.M20221011_user_contact_links_group_id -import Simplex.Chat.Migrations.M20221012_inline_files -import Simplex.Chat.Migrations.M20221019_unread_chat -import Simplex.Chat.Migrations.M20221021_auto_accept__group_links -import Simplex.Chat.Migrations.M20221024_contact_used -import Simplex.Chat.Migrations.M20221025_chat_settings -import Simplex.Chat.Migrations.M20221029_group_link_id -import Simplex.Chat.Migrations.M20221112_server_password -import Simplex.Chat.Migrations.M20221115_server_cfg -import Simplex.Chat.Migrations.M20221129_delete_group_feature_items -import Simplex.Chat.Migrations.M20221130_delete_item_deleted -import Simplex.Chat.Migrations.M20221209_verified_connection -import Simplex.Chat.Migrations.M20221210_idxs -import Simplex.Chat.Migrations.M20221211_group_description -import Simplex.Chat.Migrations.M20221212_chat_items_timed -import Simplex.Chat.Migrations.M20221214_live_message -import Simplex.Chat.Migrations.M20221222_chat_ts -import Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status -import Simplex.Chat.Migrations.M20221230_idxs -import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter -import Simplex.Chat.Migrations.M20230111_users_agent_user_id -import Simplex.Chat.Migrations.M20230117_fkey_indexes -import Simplex.Chat.Migrations.M20230118_recreate_smp_servers -import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx -import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id -import Simplex.Chat.Migrations.M20230303_group_link_role -import Simplex.Chat.Migrations.M20230317_hidden_profiles -import Simplex.Chat.Migrations.M20230318_file_description -import Simplex.Chat.Migrations.M20230321_agent_file_deleted -import Simplex.Chat.Migrations.M20230328_files_protocol -import Simplex.Chat.Migrations.M20230402_protocol_servers -import Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions -import Simplex.Chat.Migrations.M20230420_rcv_files_to_receive -import Simplex.Chat.Migrations.M20230422_profile_contact_links -import Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages -import Simplex.Chat.Migrations.M20230505_chat_item_versions -import Simplex.Chat.Migrations.M20230511_reactions -import Simplex.Chat.Migrations.M20230519_item_deleted_ts -import Simplex.Chat.Migrations.M20230526_indexes -import Simplex.Chat.Migrations.M20230529_indexes -import Simplex.Chat.Migrations.M20230608_deleted_contacts -import Simplex.Chat.Migrations.M20230618_favorite_chats -import Simplex.Chat.Migrations.M20230621_chat_item_moderations -import Simplex.Chat.Migrations.M20230705_delivery_receipts -import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses -import Simplex.Chat.Migrations.M20230814_indexes -import Simplex.Chat.Migrations.M20230827_file_encryption -import Simplex.Chat.Migrations.M20230829_connections_chat_vrange -import Simplex.Chat.Migrations.M20230903_connections_to_subscribe -import Simplex.Chat.Migrations.M20230913_member_contacts -import Simplex.Chat.Migrations.M20230914_member_probes -import Simplex.Chat.Migrations.M20230926_contact_status -import Simplex.Chat.Migrations.M20231002_conn_initiated -import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash -import Simplex.Chat.Migrations.M20231010_member_settings -import Simplex.Chat.Migrations.M20231019_indexes -import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received -import Simplex.Chat.Migrations.M20231107_indexes -import Simplex.Chat.Migrations.M20231113_group_forward -import Simplex.Chat.Migrations.M20231114_remote_control -import Simplex.Chat.Migrations.M20231126_remote_ctrl_address -import Simplex.Chat.Migrations.M20231207_chat_list_pagination -import Simplex.Chat.Migrations.M20231214_item_content_tag -import Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries -import Simplex.Chat.Migrations.M20240102_note_folders -import Simplex.Chat.Migrations.M20240104_members_profile_update -import Simplex.Chat.Migrations.M20240115_block_member_for_all -import Simplex.Chat.Migrations.M20240122_indexes -import Simplex.Chat.Migrations.M20240214_redirect_file_id -import Simplex.Chat.Migrations.M20240222_app_settings -import Simplex.Chat.Migrations.M20240226_users_restrict -import Simplex.Chat.Migrations.M20240228_pq -import Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id -import Simplex.Chat.Migrations.M20240324_custom_data -import Simplex.Chat.Migrations.M20240402_item_forwarded -import Simplex.Chat.Migrations.M20240430_ui_theme -import Simplex.Chat.Migrations.M20240501_chat_deleted -import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy -import Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays -import Simplex.Chat.Migrations.M20240528_quota_err_counter -import Simplex.Chat.Migrations.M20240827_calls_uuid -import Simplex.Chat.Migrations.M20240920_user_order -import Simplex.Chat.Migrations.M20241008_indexes -import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id -import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id -import Simplex.Chat.Migrations.M20241027_server_operators -import Simplex.Chat.Migrations.M20241125_indexes -import Simplex.Chat.Migrations.M20241128_business_chats -import Simplex.Chat.Migrations.M20241205_business_chat_members -import Simplex.Chat.Migrations.M20241206_chat_tags -import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) +import Simplex.Chat.Store.SQLite.Migrations.M20220101_initial +import Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 +import Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status +import Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests +import Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks +import Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers +import Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images +import Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes +import Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited +import Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields +import Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id +import Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply +import Simplex.Chat.Store.SQLite.Migrations.M20220702_calls +import Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id +import Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices +import Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles +import Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications +import Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id +import Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items +import Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias +import Simplex.Chat.Store.SQLite.Migrations.M20220909_commands +import Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias +import Simplex.Chat.Store.SQLite.Migrations.M20220928_settings +import Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices +import Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items +import Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id +import Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id +import Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files +import Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat +import Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links +import Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used +import Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings +import Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id +import Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password +import Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg +import Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items +import Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection +import Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs +import Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description +import Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed +import Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message +import Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts +import Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status +import Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs +import Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter +import Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id +import Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers +import Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx +import Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id +import Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role +import Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles +import Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description +import Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol +import Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers +import Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions +import Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive +import Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links +import Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages +import Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions +import Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions +import Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts +import Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts +import Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats +import Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations +import Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts +import Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses +import Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption +import Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange +import Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe +import Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts +import Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes +import Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status +import Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated +import Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash +import Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings +import Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received +import Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward +import Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control +import Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address +import Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination +import Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag +import Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries +import Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders +import Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update +import Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all +import Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id +import Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings +import Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict +import Simplex.Chat.Store.SQLite.Migrations.M20240228_pq +import Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id +import Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data +import Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded +import Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme +import Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy +import Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays +import Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter +import Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid +import Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order +import Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id +import Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id +import Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators +import Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats +import Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members +import Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions +import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags +import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports +import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = @@ -239,7 +243,11 @@ schemaMigrations = ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), - ("20241206_chat_tags", m20241206_chat_tags, Just down_m20241206_chat_tags) + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), + ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), + ("20241230_reports", m20241230_reports, Just down_m20241230_reports), + ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Migrations/M20220101_initial.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs similarity index 99% rename from src/Simplex/Chat/Migrations/M20220101_initial.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs index 2568b0b672..601a90d5d9 100644 --- a/src/Simplex/Chat/Migrations/M20220101_initial.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220101_initial where +module Simplex.Chat.Store.SQLite.Migrations.M20220101_initial where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220122_v1_1.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs similarity index 99% rename from src/Simplex/Chat/Migrations/M20220122_v1_1.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs index 157f97c333..c84bbdd03f 100644 --- a/src/Simplex/Chat/Migrations/M20220122_v1_1.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220122_v1_1 where +module Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs index 6baca156fb..fe1a6382eb 100644 --- a/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220205_chat_item_status where +module Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs index e2c26e35e0..1c97f82dc0 100644 --- a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests where +module Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20220224_messages_fks.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs index 9bb5db57a5..b9842dd33d 100644 --- a/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220224_messages_fks where +module Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20220301_smp_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs index 774f2e0168..91bd3194ca 100644 --- a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220301_smp_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs similarity index 79% rename from src/Simplex/Chat/Migrations/M20220302_profile_images.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs index 72c22b89cb..f6a9444ce1 100644 --- a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220302_profile_images where +module Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs similarity index 94% rename from src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs index 129c3616a0..fa0df67b10 100644 --- a/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220304_msg_quotes where +module Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs index 7a77f00262..5e11ef7519 100644 --- a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220321_chat_item_edited where +module Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs index 40623a3be6..f38fe1163a 100644 --- a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220404_files_status_fields where +module Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs index 239f124576..b4b99a27a2 100644 --- a/src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220514_profiles_user_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20220626_auto_reply.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs index 6ac72ac804..e85a6438ae 100644 --- a/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220626_auto_reply where +module Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220702_calls.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220702_calls.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs index 4cbf3dbad6..fc0bdd568c 100644 --- a/src/Simplex/Chat/Migrations/M20220702_calls.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220702_calls where +module Simplex.Chat.Store.SQLite.Migrations.M20220702_calls where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs index 0fa5310bfb..44ed8d2e6e 100644 --- a/src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220715_groups_chat_item_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs index a43617d439..18eabbfacd 100644 --- a/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220811_chat_items_indices where +module Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs similarity index 89% rename from src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs index e03eda2358..59ff18caf8 100644 --- a/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220812_incognito_profiles where +module Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs index ffb2b15967..42c439e8bc 100644 --- a/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220818_chat_notifications where +module Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs index bbadbd5524..1cd49ccf26 100644 --- a/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs index 40657f3421..f4f8df826a 100644 --- a/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items where +module Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs index f0b0ca8385..9252bc43fb 100644 --- a/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220824_profiles_local_alias where +module Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220909_commands.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220909_commands.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs index 745dff4165..3cc359d56b 100644 --- a/src/Simplex/Chat/Migrations/M20220909_commands.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220909_commands where +module Simplex.Chat.Store.SQLite.Migrations.M20220909_commands where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220926_connection_alias.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220926_connection_alias.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs index ede7cc3cfc..7b56ba5fb9 100644 --- a/src/Simplex/Chat/Migrations/M20220926_connection_alias.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220926_connection_alias where +module Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220928_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20220928_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs index 56b3613b05..a159ef1cc4 100644 --- a/src/Simplex/Chat/Migrations/M20220928_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220928_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20220928_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs index 10ac0fa5e1..61c5800a1c 100644 --- a/src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221001_shared_msg_id_indices where +module Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs index 2d451766cd..1843f3316a 100644 --- a/src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items where +module Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs index 0e53923b58..8dba932549 100644 --- a/src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs similarity index 81% rename from src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs index 4ad6fcb8dc..a8c64d32b0 100644 --- a/src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221011_user_contact_links_group_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221012_inline_files.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20221012_inline_files.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs index 4b069f0882..cb765d4e64 100644 --- a/src/Simplex/Chat/Migrations/M20221012_inline_files.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221012_inline_files where +module Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221019_unread_chat.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221019_unread_chat.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs index db24db947b..c8bccb81d3 100644 --- a/src/Simplex/Chat/Migrations/M20221019_unread_chat.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221019_unread_chat where +module Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs index cb945cce97..54042855d9 100644 --- a/src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221021_auto_accept__group_links where +module Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221024_contact_used.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221024_contact_used.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs index 6f677f1c77..de0164d81b 100644 --- a/src/Simplex/Chat/Migrations/M20221024_contact_used.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221024_contact_used where +module Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221025_chat_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221025_chat_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs index 712902e85c..0e768e8fb1 100644 --- a/src/Simplex/Chat/Migrations/M20221025_chat_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221025_chat_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221029_group_link_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs index da290e4158..5d3cf7ee3c 100644 --- a/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221029_group_link_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221112_server_password.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221112_server_password.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs index ee8d0e470d..4afc63fe1c 100644 --- a/src/Simplex/Chat/Migrations/M20221112_server_password.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221112_server_password where +module Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221115_server_cfg.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs index 409da91db0..1def0c4c17 100644 --- a/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221115_server_cfg where +module Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs index 7baf6b584e..5e631ab88c 100644 --- a/src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221129_delete_group_feature_items where +module Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs index 487cb7dceb..f64c3a2b56 100644 --- a/src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221130_delete_item_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221209_verified_connection.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221209_verified_connection.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs index f7e4a8aee9..0643c3f873 100644 --- a/src/Simplex/Chat/Migrations/M20221209_verified_connection.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221209_verified_connection where +module Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221210_idxs.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20221210_idxs.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs index cd18611a53..cea66cf117 100644 --- a/src/Simplex/Chat/Migrations/M20221210_idxs.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221210_idxs where +module Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221211_group_description.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20221211_group_description.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs index ba406862cb..8b0ca88f67 100644 --- a/src/Simplex/Chat/Migrations/M20221211_group_description.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221211_group_description where +module Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs index b82b66f3d4..edfcd39c70 100644 --- a/src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221212_chat_items_timed where +module Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221214_live_message.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221214_live_message.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs index 959ec75ae4..ff64defef3 100644 --- a/src/Simplex/Chat/Migrations/M20221214_live_message.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221214_live_message where +module Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs similarity index 81% rename from src/Simplex/Chat/Migrations/M20221222_chat_ts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs index 9a83c81821..1a4025b1ae 100644 --- a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221222_chat_ts where +module Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs index f29b4a9d5c..d834396df2 100644 --- a/src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status where +module Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221230_idxs.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20221230_idxs.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs index dbe84357aa..ca58d0cb70 100644 --- a/src/Simplex/Chat/Migrations/M20221230_idxs.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221230_idxs where +module Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs index b3c724e938..3b5062144a 100644 --- a/src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230107_connections_auth_err_counter where +module Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs index 531c776a33..9fd108809c 100644 --- a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230111_users_agent_user_id where +module Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs index 5986863093..50f6cf4a21 100644 --- a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230117_fkey_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs index 6253a3a37e..19e60aa792 100644 --- a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230118_recreate_smp_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs similarity index 73% rename from src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs index c45f513765..be31997235 100644 --- a/src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx where +module Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs index 085e7f7525..9c3d01795a 100644 --- a/src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id where +module Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230303_group_link_role.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20230303_group_link_role.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs index ae67e7d776..cf25fb4f6e 100644 --- a/src/Simplex/Chat/Migrations/M20230303_group_link_role.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230303_group_link_role where +module Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs index 65e9cfeadd..0106d29118 100644 --- a/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230317_hidden_profiles where +module Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230318_file_description.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20230318_file_description.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs index 39f56b2a48..3bb15037ed 100644 --- a/src/Simplex/Chat/Migrations/M20230318_file_description.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230318_file_description where +module Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs index 97c213ea48..2a54f05c4c 100644 --- a/src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230321_agent_file_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230328_files_protocol.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230328_files_protocol.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs index 5690378301..7ea2b4e34f 100644 --- a/src/Simplex/Chat/Migrations/M20230328_files_protocol.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230328_files_protocol where +module Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs index bffe7ac813..be2d0b96b2 100644 --- a/src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230402_protocol_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs index 9bfd773c44..739770d84f 100644 --- a/src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions where +module Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs index 0b6329bc6d..cd97e16c03 100644 --- a/src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230420_rcv_files_to_receive where +module Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs index ee7ff053d5..f9513acff1 100644 --- a/src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230422_profile_contact_links where +module Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs index 009b537b6c..c51db6905b 100644 --- a/src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages where +module Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs index 7e2e0f7719..bfc675c87c 100644 --- a/src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230505_chat_item_versions where +module Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230511_reactions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs similarity index 96% rename from src/Simplex/Chat/Migrations/M20230511_reactions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs index f01954a373..17ecb97649 100644 --- a/src/Simplex/Chat/Migrations/M20230511_reactions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230511_reactions where +module Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs index 20d5fd7a8d..c05687347c 100644 --- a/src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230519_item_deleted_ts where +module Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230526_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20230526_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs index 56c61b521b..19a9985c80 100644 --- a/src/Simplex/Chat/Migrations/M20230526_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230526_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230529_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230529_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs index afb12a5c6d..d112c861e5 100644 --- a/src/Simplex/Chat/Migrations/M20230529_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230529_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs index b7193300df..354fa49ca4 100644 --- a/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230608_deleted_contacts where +module Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs index 66f65a926b..4905093b0f 100644 --- a/src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230618_favorite_chats where +module Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs index 449e21e209..015a22f8b2 100644 --- a/src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230621_chat_item_moderations where +module Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs index ec59209d6b..1f4a13cc56 100644 --- a/src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230705_delivery_receipts where +module Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs index 8453da88f5..6ce99450a4 100644 --- a/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230721_group_snd_item_statuses where +module Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230814_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20230814_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs index a7419037ef..5e5b2122ca 100644 --- a/src/Simplex/Chat/Migrations/M20230814_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230814_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230827_file_encryption.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs index 2e659cac84..2378df1b61 100644 --- a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230827_file_encryption where +module Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs index 2588553a92..66d4f32d70 100644 --- a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230829_connections_chat_vrange where +module Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs index 48ad8dbf86..449e94510d 100644 --- a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230903_connections_to_subscribe where +module Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230913_member_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230913_member_contacts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs index b116373518..b3202745fd 100644 --- a/src/Simplex/Chat/Migrations/M20230913_member_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230913_member_contacts where +module Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230914_member_probes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20230914_member_probes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs index 8772b6cdad..f5725c3f39 100644 --- a/src/Simplex/Chat/Migrations/M20230914_member_probes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230914_member_probes where +module Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230926_contact_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs index b6c5dd9557..0ec499e5f6 100644 --- a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230926_contact_status where +module Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs index a0f6009af2..c7c84717bd 100644 --- a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231002_conn_initiated where +module Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs index 41c9887a04..87111c77d4 100644 --- a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash where +module Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231010_member_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs index e31203e572..faf6639ce3 100644 --- a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231010_member_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231019_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20231019_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs index 40412e1778..cb1f5ec104 100644 --- a/src/Simplex/Chat/Migrations/M20231019_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231019_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs index cf4aee2531..1a82aa6d70 100644 --- a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231030_xgrplinkmem_received where +module Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231107_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231107_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs index a4c9c5295a..07e6ce5888 100644 --- a/src/Simplex/Chat/Migrations/M20231107_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231107_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs similarity index 97% rename from src/Simplex/Chat/Migrations/M20231113_group_forward.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs index f23387f011..b83a2c780d 100644 --- a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231113_group_forward where +module Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20231114_remote_control.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs index e716b2aa63..f002c9dd3d 100644 --- a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231114_remote_control where +module Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs index 343e4ca6fa..98a464219c 100644 --- a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231126_remote_ctrl_address where +module Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs index 9a8944c5c5..f02be82919 100644 --- a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231207_chat_list_pagination where +module Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs index cd4cd136e5..06ef294702 100644 --- a/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231214_item_content_tag where +module Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs index 4b39606d8d..fa2d55e7bb 100644 --- a/src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries where +module Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240102_note_folders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs similarity index 94% rename from src/Simplex/Chat/Migrations/M20240102_note_folders.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs index 02ad741662..f06b3aa0ed 100644 --- a/src/Simplex/Chat/Migrations/M20240102_note_folders.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240102_note_folders where +module Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs index 5591c4bdcd..9e9f813a22 100644 --- a/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240104_members_profile_update where +module Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs index af2448e42c..9a43ffa55e 100644 --- a/src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240115_block_member_for_all where +module Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240122_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20240122_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs index 7b708f8bbe..cefc5eda7b 100644 --- a/src/Simplex/Chat/Migrations/M20240122_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240122_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs index da8f4d413b..010cee5ca7 100644 --- a/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240214_redirect_file_id where +module Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20240222_app_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs index e7fda06a2e..caa9b8ab77 100644 --- a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240222_app_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs similarity index 89% rename from src/Simplex/Chat/Migrations/M20240226_users_restrict.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs index a68923142c..eb1bc2bfea 100644 --- a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240226_users_restrict where +module Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20240228_pq.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs index c496d33b4b..5be3dcc458 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240228_pq where +module Simplex.Chat.Store.SQLite.Migrations.M20240228_pq where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs index c14f08447e..6c5d82ab80 100644 --- a/src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id where +module Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240324_custom_data.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20240324_custom_data.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs index bc1c4807eb..e084920ab0 100644 --- a/src/Simplex/Chat/Migrations/M20240324_custom_data.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240324_custom_data where +module Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs index 850c8be2d9..a32f210e3c 100644 --- a/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240402_item_forwarded where +module Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240430_ui_theme.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20240430_ui_theme.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs index 1f4b9805cf..a646582a89 100644 --- a/src/Simplex/Chat/Migrations/M20240430_ui_theme.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240430_ui_theme where +module Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs index a7faf33472..de8135b066 100644 --- a/src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240501_chat_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs index 3c32034344..a2fc2cef85 100644 --- a/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240510_chat_items_via_proxy where +module Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs index cd4f647685..341b6c2c41 100644 --- a/src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays where +module Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs index ea1f3a78e7..c4d121e068 100644 --- a/src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240528_quota_err_counter where +module Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs index eb1e8db65a..1d24eabb77 100644 --- a/src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240827_calls_uuid where +module Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240920_user_order.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240920_user_order.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs index 29fd1532f2..02fcf37245 100644 --- a/src/Simplex/Chat/Migrations/M20240920_user_order.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240920_user_order where +module Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241008_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20241008_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs index 94cffa8d74..a6a905a703 100644 --- a/src/Simplex/Chat/Migrations/M20241008_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241008_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs index 24e7f3a98e..b7a9b74d14 100644 --- a/src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241010_contact_requests_contact_id where +module Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs index 7f1e272026..03b5c40ed3 100644 --- a/src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id where +module Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs similarity index 96% rename from src/Simplex/Chat/Migrations/M20241027_server_operators.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs index 1316e3c006..462ab09f5d 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241027_server_operators where +module Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241125_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20241125_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs index 2115de09a3..e05b111e99 100644 --- a/src/Simplex/Chat/Migrations/M20241125_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241125_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241128_business_chats.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20241128_business_chats.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs index 2b3be38030..486250295a 100644 --- a/src/Simplex/Chat/Migrations/M20241128_business_chats.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241128_business_chats where +module Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs index 5d019d73e1..fa0cbe36da 100644 --- a/src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241205_business_chat_members where +module Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs new file mode 100644 index 0000000000..761d883ead --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241222_operator_conditions :: Query +m20241222_operator_conditions = + [sql| +ALTER TABLE operator_usage_conditions ADD COLUMN auto_accepted INTEGER DEFAULT 0; +|] + +down_m20241222_operator_conditions :: Query +down_m20241222_operator_conditions = + [sql| +ALTER TABLE operator_usage_conditions DROP COLUMN auto_accepted; +|] diff --git a/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20241206_chat_tags.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs index 2476512814..4c2d4d1745 100644 --- a/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241206_chat_tags where +module Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20241206_chat_tags :: Query -m20241206_chat_tags = +m20241223_chat_tags :: Query +m20241223_chat_tags = [sql| CREATE TABLE chat_tags ( chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -31,8 +31,8 @@ CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chat CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); |] -down_m20241206_chat_tags :: Query -down_m20241206_chat_tags = +down_m20241223_chat_tags :: Query +down_m20241223_chat_tags = [sql| DROP INDEX idx_chat_tags_user_id; DROP INDEX idx_chat_tags_user_id_chat_tag_text; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs new file mode 100644 index 0000000000..60c2b51525 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20241230_reports where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241230_reports :: Query +m20241230_reports = + [sql| +ALTER TABLE chat_items ADD COLUMN msg_content_tag TEXT; +|] + +down_m20241230_reports :: Query +down_m20241230_reports = + [sql| +ALTER TABLE chat_items DROP COLUMN msg_content_tag; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs new file mode 100644 index 0000000000..fe465710b7 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250105_indexes :: Query +m20250105_indexes = + [sql| +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_ts); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items(user_id, group_id, msg_content_tag, item_deleted, item_sent); +|] + +down_m20250105_indexes :: Query +down_m20250105_indexes = + [sql| +DROP INDEX idx_chat_items_groups_msg_content_tag_item_ts; +DROP INDEX idx_chat_items_groups_msg_content_tag_deleted; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs new file mode 100644 index 0000000000..3e52890f86 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250115_chat_ttl :: Query +m20250115_chat_ttl = + [sql| +ALTER TABLE contacts ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN local_alias TEXT DEFAULT ''; +|] + +down_m20250115_chat_ttl :: Query +down_m20250115_chat_ttl = + [sql| +ALTER TABLE contacts DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN local_alias; +|] diff --git a/src/Simplex/Chat/Migrations/chat_lint.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql similarity index 100% rename from src/Simplex/Chat/Migrations/chat_lint.sql rename to src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql similarity index 98% rename from src/Simplex/Chat/Migrations/chat_schema.sql rename to src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 5f48f4d63e..923928ad5c 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -78,6 +78,7 @@ CREATE TABLE contacts( custom_data BLOB, ui_themes TEXT, chat_deleted INTEGER NOT NULL DEFAULT 0, + chat_item_ttl INTEGER, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -131,7 +132,9 @@ CREATE TABLE groups( business_member_id BLOB NULL, business_chat TEXT NULL, business_xcontact_id BLOB NULL, - customer_member_id BLOB NULL, -- received + customer_member_id BLOB NULL, + chat_item_ttl INTEGER, + local_alias TEXT DEFAULT '', -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -402,7 +405,8 @@ CREATE TABLE chat_items( fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, - via_proxy INTEGER + via_proxy INTEGER, + msg_content_tag TEXT ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -622,6 +626,8 @@ CREATE TABLE operator_usage_conditions( conditions_commit TEXT NOT NULL, accepted_at TEXT, created_at TEXT NOT NULL DEFAULT(datetime('now')) + , + auto_accepted INTEGER DEFAULT 0 ); CREATE TABLE chat_tags( chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -959,3 +965,16 @@ CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( group_id, chat_tag_id ); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_ts +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_sent +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c6ac85dbd3..fab4c344bf 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -27,9 +28,6 @@ import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types @@ -38,8 +36,9 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -48,6 +47,15 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) import Simplex.Messaging.Version import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, SqlError, (:.) (..)) +import Database.PostgreSQL.Simple.Errors (constraintViolation) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, SQLError, (:.) (..)) +import qualified Database.SQLite.Simple as SQL +import Database.SQLite.Simple.QQ (sql) +#endif data ChatLockEntity = CLInvitation ByteString @@ -137,14 +145,32 @@ data StoreError $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 -insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" +insertedRowId db = fromOnly . head <$> DB.query_ db q + where +#if defined(dbPostgres) + q = "SELECT lastval()" +#else + q = "SELECT last_insert_rowid()" +#endif checkConstraint :: StoreError -> ExceptT StoreError IO a -> ExceptT StoreError IO a checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left . handleSQLError err) +#if defined(dbPostgres) +type SQLError = SqlError +#endif + +constraintError :: SQLError -> Bool +#if defined(dbPostgres) +constraintError = isJust . constraintViolation +#else +constraintError e = SQL.sqlError e == SQL.ErrorConstraint +#endif +{-# INLINE constraintError #-} + handleSQLError :: StoreError -> SQLError -> StoreError handleSQLError err e - | SQL.sqlError e == SQL.ErrorConstraint = err + | constraintError e = err | otherwise = SEInternalError $ show e storeFinally :: ExceptT StoreError IO a -> ExceptT StoreError IO b -> ExceptT StoreError IO a @@ -168,12 +194,12 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, BoolInt, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -227,9 +253,9 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, connStatus, connType) + ( (userId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, customUserProfileId, connStatus, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (connChatVersion, minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) + :. (connChatVersion, minV, maxV, BI (subMode == SMOnlyCreate), pqSup, pqSup) ) connId <- insertedRowId db pure @@ -269,7 +295,7 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) VALUES (?,?,?,?,?,?,?) |] - (displayName, fullName, image, userId, Just True, createdAt, createdAt) + (displayName, fullName, image, userId, Just (BI True), createdAt, createdAt) insertedRowId db updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () @@ -366,40 +392,40 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, contactUsed) + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI contactUsed) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () deleteUnusedIncognitoProfileById_ db User {userId} profileId = - DB.executeNamed + DB.execute db [sql| DELETE FROM contact_profiles - WHERE user_id = :user_id AND contact_profile_id = :profile_id AND incognito = 1 + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 AND 1 NOT IN ( SELECT 1 FROM connections - WHERE user_id = :user_id AND custom_user_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id AND member_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND member_profile_id = ? LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profileId] + (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, Bool, Maybe UIThemeEntityOverrides, Bool, Maybe CustomData) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = @@ -434,8 +460,8 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, Bool, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User -toUser ((userId, auId, userContactId, profileId, activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} @@ -462,15 +488,15 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate getLdnSuffix :: IO Int getLdnSuffix = maybe 0 ((+ 1) . fromOnly) . listToMaybe - <$> DB.queryNamed + <$> DB.query db [sql| SELECT ldn_suffix FROM display_names - WHERE user_id = :user_id AND ldn_base = :display_name + WHERE user_id = ? AND ldn_base = ? ORDER BY ldn_suffix DESC LIMIT 1 |] - [":user_id" := userId, ":display_name" := displayName] + (userId, displayName) tryCreateName :: Int -> Int -> IO (Either StoreError a) tryCreateName _ 0 = pure $ Left SEDuplicateName tryCreateName ldnSuffix attempts = do @@ -479,7 +505,7 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate E.try (insertName ldn currentTs) >>= \case Right () -> action ldn Left e - | SQL.sqlError e == SQL.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1) + | constraintError e -> tryCreateName (ldnSuffix + 1) (attempts - 1) | otherwise -> E.throwIO e where insertName ldn ts = @@ -511,7 +537,7 @@ createWithRandomBytes' size gVar create = tryCreate 3 liftIO (E.try $ create id') >>= \case Right x -> liftEither x Left e - | SQL.sqlError e == SQL.ErrorConstraint -> tryCreate (n - 1) + | constraintError e -> tryCreate (n - 1) | otherwise -> throwError . SEInternalError $ show e encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString @@ -549,21 +575,21 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -581,9 +607,9 @@ groupInfoQuery = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 2ff9e60699..829bdc3d31 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} @@ -6,14 +7,8 @@ module Simplex.Chat.Terminal where -import Control.Exception (handle, throwIO) import Control.Monad -import qualified Data.ByteArray as BA import qualified Data.List.NonEmpty as L -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Database.SQLite.Simple (SQLError (..)) -import qualified Database.SQLite.Simple as DB import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) import Simplex.Chat.Controller import Simplex.Chat.Core @@ -21,12 +16,21 @@ import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) +#if !defined(dbPostgres) +import Control.Exception (handle, throwIO) +import qualified Data.ByteArray as BA +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB import System.IO (hFlush, hSetEcho, stdin, stdout) +#endif terminalChatConfig :: ChatConfig terminalChatConfig = @@ -61,7 +65,14 @@ terminalChatConfig = simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChatTerminal cfg options t = run options where - run opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbKey}} = +#if defined(dbPostgres) + run opts = + simplexChatCore cfg opts $ \u cc -> do + ct <- newChatTerminal t opts + when (firstTime cc) . printToTerminal ct $ chatWelcome u + runChatTerminal ct cc opts +#else + run opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions}} = handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u @@ -70,7 +81,7 @@ simplexChatTerminal cfg options t = run options checkDBKeyError :: SQLError -> IO () checkDBKeyError e = case sqlError e of DB.ErrorNotADatabase -> do - putStrLn $ "Database file is invalid or " <> if BA.null dbKey then "encrypted." else "you passed an incorrect encryption key." + putStrLn $ "Database file is invalid or " <> if BA.null (dbKey dbOptions) then "encrypted." else "you passed an incorrect encryption key." run =<< getKeyOpts _ -> throwIO e getKeyOpts :: IO ChatOpts @@ -81,7 +92,8 @@ simplexChatTerminal cfg options t = run options key <- getLine hSetEcho stdin True putStrLn "" - pure opts {coreOptions = coreOptions {dbKey = BA.convert $ encodeUtf8 $ T.pack key}} + pure opts {coreOptions = coreOptions {dbOptions = dbOptions {dbKey = BA.convert $ encodeUtf8 $ T.pack key}}} +#endif runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO () runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 836bcd7ec8..bf48d1d4f5 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -25,9 +26,6 @@ import Data.Maybe (isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Database.SQLite.Simple (Only (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import GHC.Weak (deRefWeak) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands @@ -36,12 +34,19 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Styled import Simplex.Chat.Terminal.Output import Simplex.Chat.Types (User (..)) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM) import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, ToRow) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, ToRow) +import Database.SQLite.Simple.QQ (sql) +#endif getKey :: MonadTerminal m => m (Key, Modifiers) getKey = @@ -223,7 +228,7 @@ data AutoComplete | ACCommand Text | ACNone -updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState +updateTermState :: Maybe User -> DBStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of CharKey c | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c] @@ -321,7 +326,7 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr getNameSfxs table pfx = getNameSfxs_ pfx (userId, pfx <> "%") $ "SELECT local_display_name FROM " <> table <> " WHERE user_id = ? AND local_display_name LIKE ?" - getNameSfxs_ :: SQL.ToRow p => Text -> p -> SQL.Query -> IO [String] + getNameSfxs_ :: ToRow p => Text -> p -> Query -> IO [String] getNameSfxs_ pfx ps q = withTransaction st (\db -> hasPfx pfx . map fromOnly <$> DB.query db q ps) `catchAll_` pure [] commands = diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index b0eb4dac88..aa9adb059f 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -13,6 +13,7 @@ import Network.Socket import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Terminal import Simplex.Chat.View (serializeChatResponse, smpProxyModeStr) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (..)) @@ -56,11 +57,11 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r welcome :: ChatConfig -> ChatOpts -> IO () -welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = +welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbOptions, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = mapM_ putStrLn [ versionString versionNumber, - "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db", + "db: " <> dbString dbOptions, maybe "direct network connection - use `/network` command or `-x` CLI option to connect via SOCKS5 at :9050" ((\sp -> "using SOCKS5 proxy " <> sp <> if socksMode == SMOnion then " for onion servers ONLY." else " for ALL servers.") . show) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 716925a0d7..d137e54d23 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1,17 +1,21 @@ {-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} @@ -29,6 +33,7 @@ import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) +import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -37,11 +42,6 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -49,13 +49,23 @@ import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ResultError (..)) +import Database.PostgreSQL.Simple.FromField (FromField(..), FieldParser, returnError) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple (ResultError (..)) +import Database.SQLite.Simple.FromField (FromField (..), FieldParser, returnError) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif class IsContact a where contactId' :: a -> ContactId @@ -98,7 +108,7 @@ instance ToJSON AgentUserId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f +deriving newtype instance FromField AgentUserId instance ToField AgentUserId where toField (AgentUserId uId) = toField uId @@ -131,10 +141,9 @@ data NewUser = NewUser newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField B64UrlByteString where fromField f = B64UrlByteString <$> fromField f - -instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField m +instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField $ Binary m instance StrEncoding B64UrlByteString where strEncode (B64UrlByteString m) = strEncode m @@ -179,6 +188,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, chatDeleted :: Bool, customData :: Maybe CustomData @@ -195,9 +205,9 @@ instance ToJSON CustomData where instance FromJSON CustomData where parseJSON = J.withObject "CustomData" (pure . CustomData) -instance ToField CustomData where toField (CustomData v) = toField $ J.encode v +instance ToField CustomData where toField (CustomData v) = toField . Binary . LB.toStrict $ J.encode v -instance FromField CustomData where fromField = fromBlobField_ J.eitherDecodeStrict +instance FromField CustomData where fromField = blobFieldDecoder J.eitherDecodeStrict contactConn :: Contact -> Maybe Connection contactConn Contact {activeConn} = activeConn @@ -316,10 +326,9 @@ data UserContactRequest = UserContactRequest newtype XContactId = XContactId ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField XContactId where fromField f = XContactId <$> fromField f - -instance ToField XContactId where toField (XContactId m) = toField m +instance ToField XContactId where toField (XContactId m) = toField $ Binary m instance StrEncoding XContactId where strEncode (XContactId m) = strEncode m @@ -335,10 +344,9 @@ instance ToJSON XContactId where newtype ConnReqUriHash = ConnReqUriHash {unConnReqUriHash :: ByteString} deriving (Eq, Show) + deriving newtype (FromField) -instance FromField ConnReqUriHash where fromField f = ConnReqUriHash <$> fromField f - -instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField m +instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField $ Binary m instance StrEncoding ConnReqUriHash where strEncode (ConnReqUriHash m) = strEncode m @@ -365,6 +373,26 @@ optionalFullName displayName fullName | T.null fullName || displayName == fullName = "" | otherwise = " (" <> fullName <> ")" +data ShortGroup = ShortGroup + { shortInfo :: ShortGroupInfo, + members :: [ShortGroupMember] + } + +data ShortGroupInfo = ShortGroupInfo + { groupId :: GroupId, + groupName :: GroupName, + membershipStatus :: GroupMemberStatus + } + deriving (Eq, Show) + +data ShortGroupMember = ShortGroupMember + { groupMemberId :: GroupMemberId, + groupId :: GroupId, + memberName :: ContactName, + connId :: AgentConnId + } + deriving (Show) + data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -374,6 +402,7 @@ data GroupInfo = GroupInfo { groupId :: GroupId, localDisplayName :: GroupName, groupProfile :: GroupProfile, + localAlias :: Text, businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, @@ -384,6 +413,7 @@ data GroupInfo = GroupInfo chatTs :: Maybe UTCTime, userMemberProfileSentAt :: Maybe UTCTime, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData } @@ -415,12 +445,12 @@ data GroupSummary = GroupSummary } deriving (Show) -data ContactOrGroup = CGContact Contact | CGGroup Group +data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case CGContact Contact {contactId} -> (Just contactId, Nothing) - CGGroup (Group GroupInfo {groupId} _) -> (Nothing, Just groupId) + CGGroup GroupInfo {groupId} _ -> (Nothing, Just groupId) -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings @@ -457,13 +487,16 @@ msgFilterIntP = \case 2 -> Just MFMentions _ -> Just MFAll -fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a -fromIntField_ fromInt = \case - f@(Field (SQLInteger i) _) -> - case fromInt i of - Just x -> Ok x - _ -> returnError ConversionFailed f ("invalid integer: " <> show i) - f -> returnError ConversionFailed f "expecting SQLInteger column type" +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> FieldParser a +#if defined(dbPostgres) +fromIntField_ fromInt f val = fromField f val >>= parseInt +#else +fromIntField_ fromInt f = fromField f >>= parseInt +#endif + where + parseInt i = case fromInt i of + Just x -> pure x + _ -> returnError ConversionFailed f $ "invalid integer: " <> show i featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = @@ -593,16 +626,15 @@ instance ToJSON ImageData where instance ToField ImageData where toField (ImageData t) = toField t -instance FromField ImageData where fromField = fmap ImageData . fromField +deriving newtype instance FromField ImageData data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId} newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link deriving (Eq, Show) + deriving newtype (FromField) -instance FromField GroupLinkId where fromField f = GroupLinkId <$> fromField f - -instance ToField GroupLinkId where toField (GroupLinkId g) = toField g +instance ToField GroupLinkId where toField (GroupLinkId g) = toField $ Binary g instance StrEncoding GroupLinkId where strEncode (GroupLinkId g) = strEncode g @@ -679,7 +711,7 @@ data MemberRestrictionStatus | MRSUnknown Text deriving (Eq, Show) -instance FromField MemberRestrictionStatus where fromField = fromBlobField_ strDecode +instance FromField MemberRestrictionStatus where fromField = blobFieldDecoder strDecode instance ToField MemberRestrictionStatus where toField = toField . strEncode @@ -808,10 +840,9 @@ data NewGroupMember = NewGroupMember newtype MemberId = MemberId {unMemberId :: ByteString} deriving (Eq, Show) + deriving newtype (FromField) -instance FromField MemberId where fromField f = MemberId <$> fromField f - -instance ToField MemberId where toField (MemberId m) = toField m +instance ToField MemberId where toField (MemberId m) = toField $ Binary m instance StrEncoding MemberId where strEncode (MemberId m) = strEncode m @@ -1162,6 +1193,9 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft newtype AgentConnId = AgentConnId ConnId deriving (Eq, Ord, Show) + deriving newtype (FromField) + +instance ToField AgentConnId where toField (AgentConnId m) = toField $ Binary m instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId @@ -1175,12 +1209,11 @@ instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f - -instance ToField AgentConnId where toField (AgentConnId m) = toField m - newtype AgentSndFileId = AgentSndFileId SndFileId deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField $ Binary m instance StrEncoding AgentSndFileId where strEncode (AgentSndFileId connId) = strEncode connId @@ -1194,12 +1227,11 @@ instance ToJSON AgentSndFileId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromField f - -instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m - newtype AgentRcvFileId = AgentRcvFileId RcvFileId deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField AgentRcvFileId where toField (AgentRcvFileId m) = toField $ Binary m instance StrEncoding AgentRcvFileId where strEncode (AgentRcvFileId connId) = strEncode connId @@ -1213,10 +1245,6 @@ instance ToJSON AgentRcvFileId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentRcvFileId where fromField f = AgentRcvFileId <$> fromField f - -instance ToField AgentRcvFileId where toField (AgentRcvFileId m) = toField m - newtype AgentInvId = AgentInvId InvitationId deriving (Eq, Show) @@ -1232,7 +1260,7 @@ instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentInvId where fromField f = AgentInvId <$> fromField f +deriving newtype instance FromField AgentInvId instance ToField AgentInvId where toField (AgentInvId m) = toField m @@ -1804,3 +1832,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) + +$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) + +$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 8465caeee0..07e32e7d56 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -29,13 +29,11 @@ import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Records.Compat +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) data ChatFeature @@ -678,7 +676,7 @@ data FeatureAllowed | FANo -- do not allow deriving (Eq, Show) -instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode +instance FromField FeatureAllowed where fromField = blobFieldDecoder strDecode instance ToField FeatureAllowed where toField = toField . strEncode @@ -704,7 +702,7 @@ instance ToJSON FeatureAllowed where data GroupFeatureEnabled = FEOn | FEOff deriving (Eq, Show) -instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode +instance FromField GroupFeatureEnabled where fromField = blobFieldDecoder strDecode instance ToField GroupFeatureEnabled where toField = toField . strEncode diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index f44457160f..d5c8f48776 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -6,21 +6,21 @@ module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types.Util +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (blobFieldDecoder) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members + | GRModerator -- + moderate messages and block members (excl. Admins and Owners) | GRAdmin -- + add/remove members, change member role (excl. Owners) | GROwner -- + delete and change group information, add/remove/change roles for Owners deriving (Eq, Show, Ord) -instance FromField GroupMemberRole where fromField = fromBlobField_ strDecode +instance FromField GroupMemberRole where fromField = blobFieldDecoder strDecode instance ToField GroupMemberRole where toField = toField . strEncode @@ -28,12 +28,14 @@ instance StrEncoding GroupMemberRole where strEncode = \case GROwner -> "owner" GRAdmin -> "admin" + GRModerator -> "moderator" GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" strDecode = \case "owner" -> Right GROwner "admin" -> Right GRAdmin + "moderator" -> Right GRModerator "member" -> Right GRMember "author" -> Right GRAuthor "observer" -> Right GRObserver diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index cc5290aa69..f2512a3a5a 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -13,8 +13,7 @@ import qualified Data.Aeson.TH as JQ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index 47edf8eaf8..afea178e41 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -1,24 +1,8 @@ -{-# LANGUAGE LambdaCase #-} - module Simplex.Chat.Types.Util where import qualified Data.Aeson as J import qualified Data.Aeson.Types as JT -import Data.ByteString (ByteString) -import Data.Typeable -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (FieldParser, returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok (Ok (Ok)) import Simplex.Messaging.Encoding.String textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode - -fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k -fromBlobField_ p = \case - f@(Field (SQLBlob b) _) -> - case p b of - Right k -> Ok k - Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) - f -> returnError ConversionFailed f "expecting SQLBlob column type" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index cb117cd4a5..1d578dea24 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} @@ -58,7 +59,6 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -66,13 +66,16 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Version hiding (version) import Simplex.RemoteControl.Types (RCCtrlAddress (..), RCErrorType (..)) import System.Console.ANSI.Types +#if !defined(dbPostgres) +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif type CurrentTime = UTCTime @@ -156,6 +159,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView deletions' -> ttyUser u [sShow (length deletions') <> " messages deleted"] + CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRReactionMembers u memberReactions -> ttyUser u $ viewReactionMembers memberReactions CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] @@ -233,6 +237,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserProfileImage u p -> ttyUser u $ viewUserProfileImage p CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c + CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRGroupMemberUpdated {} -> [] @@ -287,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitation' g] + CRGroupInvitation u g -> ttyUser u [groupInvitationSub g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -302,8 +307,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g @@ -318,9 +322,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g CRPendingSubSummary u _ -> ttyUser u [] CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] @@ -389,6 +393,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows +#if !defined(dbPostgres) + CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] + CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = ("count: " <> sShow count) @@ -396,6 +403,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> (" :: avg: " <> sShow timeAvg <> " ms") <> (" :: " <> plain (T.unwords $ T.lines query)) in ("Chat queries" : map viewQuery chatQueries) <> [""] <> ("Agent queries" : map viewQuery agentQueries) +#endif CRDebugLocks {chatLockName, chatEntityLocks, agentLocks} -> [ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName, "chat entity locks: " <> viewJSON chatEntityLocks, @@ -440,8 +448,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e CRChatError u e -> ttyUser' u $ viewChatError False logLevel testView e CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs - CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] - CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRAppSettings as -> ["app settings: " <> viewJSON as] CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r @@ -564,8 +570,8 @@ viewUsersList us = <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -viewGroupSubscribed :: GroupInfo -> [StyledString] -viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: GroupName -> [StyledString] +viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1176,7 +1182,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case GSMemRemoved -> delete "you are removed" @@ -1191,6 +1197,9 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" + alias GroupInfo {localAlias} + | localAlias == "" = "" + | otherwise = " (alias: " <> plain localAlias <> ")" groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = @@ -1206,6 +1215,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " +groupInvitationSub :: ShortGroupInfo -> StyledString +groupInvitationSub ShortGroupInfo {groupName = ldn} = + highlight ("#" <> viewName ldn) + <> " - you are invited (" + <> highlight ("/j " <> viewName ldn) + <> " to join, " + <> highlight ("/d #" <> viewName ldn) + <> " to delete invitation)" + viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, @@ -1314,7 +1332,7 @@ viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of viewOpConditions :: ConditionsAcceptance -> Text viewOpConditions = \case - CAAccepted ts -> viewCond "accepted" ts + CAAccepted ts _ -> viewCond "accepted" ts CARequired ts -> viewCond "required" ts where viewCond w ts = w <> maybe "" (parens . tshow) ts @@ -1353,11 +1371,13 @@ viewUsageConditions current accepted_ = viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case - Nothing -> ["old messages are not being deleted"] + Nothing -> ["old messages are set to delete according to default user config"] Just ttl + | ttl == 0 -> ["old messages are not being deleted"] | ttl == 86400 -> deletedAfter "one day" | ttl == 7 * 86400 -> deletedAfter "one week" | ttl == 30 * 86400 -> deletedAfter "one month" + | ttl == 365 * 86400 -> deletedAfter "one year" | otherwise -> deletedAfter $ sShow ttl <> " second(s)" where deletedAfter ttlStr = ["old messages are set to be deleted after: " <> ttlStr] @@ -1620,6 +1640,11 @@ viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} | localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"] | otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias] +viewGroupAliasUpdated :: GroupInfo -> [StyledString] +viewGroupAliasUpdated g@GroupInfo {localAlias} + | localAlias == "" = ["group " <> ttyGroup' g <> " alias removed"] + | otherwise = ["group " <> ttyGroup' g <> " alias updated: " <> plain localAlias] + viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString] viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} | localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"] @@ -2215,9 +2240,14 @@ viewChatError isCmd logLevel testView = \case CMD PROHIBITED cxt -> [withConnEntity <> plain ("error: command is prohibited, " <> cxt)] SMP _ SMP.AUTH -> [ withConnEntity - <> "error: connection authorization failed - this could happen if connection was deleted,\ - \ secured with different credentials, or due to a bug - please re-create the connection" + <> "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" ] + SMP _ (SMP.BLOCKED BlockingInfo {reason}) -> + [withConnEntity <> "error: connection blocked by server operator: " <> reasonStr] + where + reasonStr = case reason of + BRSpam -> "spam" + BRContent -> "content violates conditions of use" BROKER _ NETWORK | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd] diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 95c80d6345..7d10f6a34a 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -13,9 +14,12 @@ import Control.Exception (bracket) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) -import System.FilePath (()) import Test.Hspec hiding (it) +#if !defined(dbPostgres) +import System.FilePath (()) +#endif broadcastBotTests :: SpecWith FilePath broadcastBotTests = do @@ -33,7 +37,17 @@ broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadc mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts mkBotOpts tmp publishers = BroadcastBotOpts - { coreOptions = testCoreOpts {dbFilePrefix = tmp botDbPrefix}, + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> botDbPrefix} +#else + {dbFilePrefix = tmp botDbPrefix} +#endif + + }, publishers, welcomeMessage = defaultWelcomeMessage publishers, prohibitedMessage = defaultWelcomeMessage publishers diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index c50bb8b02d..7a0ae79ccc 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -9,7 +10,7 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import qualified Data.Text as T import qualified Directory.Events as DE import Directory.Options @@ -20,6 +21,7 @@ import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) @@ -34,12 +36,14 @@ directoryServiceTests = do it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces it "should return more groups in search, all and recent groups" testSearchGroups + it "should invite to owners' group if specified" testInviteToOwnersGroup describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved it "should de-list if service is removed from the group" testDelistedServiceRemoved + it "should de-list if group is deleted" testDelistedGroupDeleted it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges it "should NOT de-list if another member role changes" testNotDelistedMemberRoleChanged it "should NOT send to approval if roles are incorrect" testNotSentApprovalBadRoles @@ -64,14 +68,26 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts -mkDirectoryOpts tmp superUsers = +mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts tmp superUsers ownersGroup = DirectoryOpts - { coreOptions = testCoreOpts {dbFilePrefix = tmp serviceDbPrefix}, + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> serviceDbPrefix} +#else + {dbFilePrefix = tmp serviceDbPrefix} +#endif + + }, adminUsers = [], superUsers, + ownersGroup, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", + runCLI = False, searchResults = 3, testing = True } @@ -419,6 +435,24 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") +testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () +testInviteToOwnersGroup tmp = + withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> + withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroupId superUser bob "privacy" "Privacy" 2 1 + bob <## "#owners: SimpleX-Directory invites you to join the group as member" + bob <## "use /j owners to accept" + superUser <## "Invited @bob, the owner of the group ID 2 (privacy) to owners' group owners" + bob ##> "/j owners" + bob <## "#owners: you joined the group" + bob <## "#owners: member alice (Alice) is connected" + superUser <## "#owners: SimpleX-Directory added bob (Bob) to the group (connecting...)" + superUser <## "#owners: new member bob is connected" + -- second group + registerGroupId superUser bob "security" "Security" 3 2 + superUser <## "Owner is already a member of owners' group" + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -492,6 +526,30 @@ testDelistedServiceRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." groupNotFound cath "privacy" +testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () +testDelistedGroupDeleted tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + connectUsers bob cath + fullAddMember "privacy" "Privacy" bob cath GROwner + joinGroup "privacy" cath bob + cath <## "#privacy: member SimpleX-Directory_1 is connected" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" + bob ##> "/d #privacy" + bob <## "#privacy: you deleted the group" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is deleted." + bob <## "" + bob <## "The group is no longer listed in the directory." + cath <## "#privacy: bob deleted the group" + cath <## "use /d #privacy to delete the local copy of the group" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." + groupNotFound cath "privacy" + testDelistedRoleChanges :: HasCallStack => FilePath -> IO () testDelistedRoleChanges tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -865,13 +923,13 @@ testRestoreDirectory tmp = do withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob - <### [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#security: connected to server(s)" ] cath <## "2 contacts connected (use /cs for the list)" cath - <### [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#anonymity: connected to server(s)" ] listGroups superUser bob cath groupFoundN 3 bob "privacy" @@ -967,14 +1025,28 @@ withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg test = do +withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False + +withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do dsLink <- withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser + when createOwnersGroup $ do + superUser ##> "/g owners" + superUser <## "group #owners is created" + superUser <## "to add members use /a owners or /create link #owners" + superUser ##> "/a owners SimpleX-Directory admin" + superUser <## "invitation to join the group #owners sent to SimpleX-Directory" + ds <## "#owners: alice invites you to join the group as admin" + ds <## "use /j owners to accept" + ds ##> "/j owners" + ds <## "#owners: you joined the group" + superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectory tmp cfg dsLink test + withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () restoreDirectoryService tmp ctCount grCount test = do @@ -991,11 +1063,16 @@ restoreDirectoryService tmp ctCount grCount test = do withDirectory tmp testCfg dsLink test withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] +withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False + +withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ withTestChatCfg tmp cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" + when createOwnersGroup $ + superUser <## "#owners: connected to server(s)" test superUser dsLink runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 585ef70f6e..32af1d5c95 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -18,7 +19,6 @@ import Control.Exception (bracket, bracket_) import Control.Monad import Control.Monad.Except import Control.Monad.Reader -import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) @@ -29,6 +29,7 @@ import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatControlle import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -43,8 +44,9 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) @@ -58,14 +60,28 @@ import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransp import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) -import System.FilePath (()) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) import System.Timeout (timeout) import Test.Hspec (Expectation, HasCallStack, shouldReturn) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) +#else +import Data.ByteArray (ScrubbedBytes) +import System.FilePath (()) +#endif -testDBPrefix :: FilePath -testDBPrefix = "tests/tmp/test" +#if defined(dbPostgres) +testDBConnstr :: String +testDBConnstr = "postgresql://test_chat_user@/test_chat_db" + +testDBConnectInfo :: ConnectInfo +testDBConnectInfo = + defaultConnectInfo { + connectUser = "test_chat_user", + connectDatabase = "test_chat_db" + } +#endif serverPort :: ServiceName serverPort = "7001" @@ -92,9 +108,19 @@ testOpts = testCoreOpts :: CoreChatOpts testCoreOpts = CoreChatOpts - { dbFilePrefix = "./simplex_v1", - dbKey = "", - -- dbKey = "this is a pass-phrase to encrypt the database", + { + dbOptions = ChatDbOpts +#if defined(dbPostgres) + { dbConnstr = testDBConnstr, + -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), + -- instead different schema prefix is passed per client so that single test database is used + dbSchemaPrefix = "" +#else + { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) + dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", + vacuumOnMigration = True +#endif + }, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], simpleNetCfg = defaultSimpleNetCfg, @@ -108,8 +134,10 @@ testCoreOpts = yesToUpMigrations = False } +#if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts -getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbKey}} +getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbOptions = (dbOptions testCoreOpts) {dbKey}}} +#endif termSettings :: VirtualTerminalSettings termSettings = @@ -143,7 +171,7 @@ testAgentCfgSlow = testAgentCfg { smpClientVRange = mkVersionRange (Version 1) srvHostnamesSMPClientVersion, -- v2 smpAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion pqdrSMPAgentVersion, -- v5 - smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} -- v8 + smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange minClientSMPRelayVersion sendingProxySMPVersion} -- v8 } testCfg :: ChatConfig @@ -182,7 +210,7 @@ testAgentCfgV1 = { smpClientVRange = v1Range, smpAgentVRange = versionToRange duplexHandshakeSMPAgentVersion, e2eEncryptVRange = versionToRange CR.kdfX3DHE2EEncryptVersion, - smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} + smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange minClientSMPRelayVersion} } testCfgVPrev :: ChatConfig @@ -246,18 +274,33 @@ groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError - withTransaction agentStore (`DB.execute_` "INSERT INTO users (user_id) VALUES (1);") +createTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createDatabase tmp coreOptions dbPrefix + insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError +startTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createDatabase tmp coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user +createDatabase :: FilePath -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) +#if defined(dbPostgres) +createDatabase _tmp CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError + +insertUser :: DBStore -> IO () +insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") +#else +createDatabase tmp CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbFilePrefix = tmp dbPrefix} MCError + +insertUser :: DBStore -> IO () +insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") +#endif + startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure @@ -276,7 +319,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeSQLiteStore chatStore + closeDBStore chatStore threadDelay 200000 withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index de12caf648..dd88aac54e 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -193,9 +193,9 @@ testPaginationAllChatTypes = _ts6 <- iso8601Show <$> getCurrentTime - -- * (notes) + -- \* (notes) createCCNoteFolder alice - alice /* "psst" + alice >* "psst" ts7 <- iso8601Show <$> getCurrentTime diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 95d27801f6..bc857132eb 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -21,27 +22,31 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (intercalate) import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) -import System.FilePath (()) import Test.Hspec hiding (it) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +import System.FilePath (()) +#endif chatDirectTests :: SpecWith FilePath chatDirectTests = do @@ -106,10 +111,12 @@ chatDirectTests = do xit'' "curr/v5" $ testFullAsyncSlow testCfg testCfgSlow describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall +#if !defined(dbPostgres) describe "maintenance mode" $ do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption +#endif describe "coordination between app and NSE" $ do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do @@ -127,6 +134,7 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notifications" testUserPrivacy + it "set direct chat expiration TTL" testSetDirectChatTTL describe "settings" $ do it "set chat item expiration TTL" testSetChatItemTTL it "save/get app settings" testAppSettings @@ -142,10 +150,13 @@ chatDirectTests = do sameVerificationCode "123 456 789" "12345 6789" `shouldBe` True it "mark contact verified" testMarkContactVerified it "mark group member verified" testMarkGroupMemberVerified +#if !defined(dbPostgres) + -- TODO [postgres] restore from outdated db backup (same as in agent) describe "message errors" $ do it "show message decryption error" testMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset +#endif describe "message reactions" $ do it "set message reactions" testSetMessageReactions describe "delivery receipts" $ do @@ -1231,14 +1242,14 @@ testOperators = alice ##> "/_conditions" alice <##. "Current conditions: 2." alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " -- set conditions notified alice ##> "/_conditions_notified 2" alice <## "ok" alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice ##> "/_conditions" alice <##. "Current conditions: 2 (notified)." @@ -1363,6 +1374,7 @@ testNegotiateCall = bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: accepted")]) alice <## "bob accepted your WebRTC video call (e2e encrypted)" repeatM_ 3 $ getTermLine alice + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: accepted")]) -- alice confirms call by sending WebRTC answer alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) @@ -1480,6 +1492,7 @@ testMaintenanceModeWithFiles tmp = withXFTPServer $ do -- works after full restart withTestChat tmp "alice" $ \alice -> testChatWorking alice bob +#if !defined(dbPostgres) testDatabaseEncryption :: HasCallStack => FilePath -> IO () testDatabaseEncryption tmp = do withNewTestChat tmp "bob" bobProfile $ \bob -> do @@ -1527,6 +1540,7 @@ testDatabaseEncryption tmp = do alice <## "ok" withTestChat tmp "alice" $ \alice -> do testChatWorking alice bob +#endif testSubscribeAppNSE :: HasCallStack => FilePath -> IO () testSubscribeAppNSE tmp = @@ -2103,7 +2117,7 @@ testUsersRestartCIExpiration tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 4000000 alice #$> ("/_get chat @6 count=100", chat, []) where @@ -2548,6 +2562,82 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + alice #> "@bob 1" + bob <# "alice> 1" + bob #> "@alice 2" + alice <# "bob> 2" + -- above items should be deleted after we set ttl + alice #> "@cath 10" + cath <# "alice> 10" + cath #> "@alice 11" + alice <# "cath> 11" + alice #$> ("/ttl @cath none", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are not being deleted") + + threadDelay 3000000 + alice #> "@bob 3" + bob <# "alice> 3" + bob #> "@alice 4" + alice <# "bob> 4" + alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, "3"), Nothing), ((0, "4"), Nothing)]) + alice #$> ("/_ttl 1 2", id, "ok") + -- when expiration is turned on, first cycle is synchronous + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")]) + + -- chat @3 doesn't expire since it was set to not expire + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4")]) + + -- remove global ttl + alice #$> ("/ttl none", id, "ok") + alice #> "@bob 5" + bob <# "alice> 5" + bob #> "@alice 6" + alice <# "bob> 6" + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + + -- set ttl for chat @3, only chat @3 is affected since global ttl is disabled + alice #$> ("/_ttl 1 @3 1", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: 1 second(s)") + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl to never expire again + alice #$> ("/ttl @cath none", id, "ok") + alice #> "@cath 12" + cath <# "alice> 12" + cath #> "@alice 13" + alice <# "cath> 13" + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, [(1, "12"), (0, "13")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl back to default + alice #$> ("/ttl @cath default", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to delete according to default user config") + alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, []) + + alice #$> ("/ttl @cath day", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one day") + alice #$> ("/ttl @cath week", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one week") + alice #$> ("/ttl @cath month", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one month") + alice #$> ("/ttl @cath year", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") + testAppSettings :: HasCallStack => FilePath -> IO () testAppSettings tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -2730,6 +2820,7 @@ testMarkGroupMemberVerified = | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#if !defined(dbPostgres) testMsgDecryptError :: HasCallStack => FilePath -> IO () testMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -2779,8 +2870,8 @@ setupDesynchronizedRatchet tmp alice = do (alice from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testSyncRatchet :: HasCallStack => FilePath -> IO () testSyncRatchet tmp = @@ -2867,6 +2958,7 @@ testSyncRatchetCodeReset tmp = connVerified | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#endif testSetMessageReactions :: HasCallStack => FilePath -> IO () testSetMessageReactions = diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index f347e1a396..6a1edfe3d4 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -224,7 +224,7 @@ testForwardNotesToContact = createCCNoteFolder alice connectUsers alice cath - alice /* "hi" + alice >* "hi" alice `send` "@cath <- * hi" alice <# "@cath hi" @@ -237,7 +237,7 @@ testForwardNotesToGroup = createCCNoteFolder alice createGroup2 "team" alice cath - alice /* "hi" + alice >* "hi" alice `send` "#team <- * hi" alice <# "#team hi" @@ -248,7 +248,7 @@ testForwardNotesToNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "hi" + alice >* "hi" alice `send` "* <- * hi" alice <# "* hi" @@ -785,8 +785,9 @@ testMultiForwardFiles = bob <## " message without file" bob <# "@cath <- @alice" - bob <## " test_1.jpg" - bob <# "/f @cath test_1.jpg" + + jpgFileName <- T.unpack . T.strip . T.pack <$> getTermLine bob + bob <# ("/f @cath " <> jpgFileName) bob <## "use /fc 5 to cancel sending" bob <# "@cath <- @alice" @@ -808,8 +809,8 @@ testMultiForwardFiles = cath <## " message without file" cath <# "bob> -> forwarded" - cath <## " test_1.jpg" - cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)" + cath <## (" " <> jpgFileName) + cath <# ("bob> sends file " <> jpgFileName <> " (136.5 KiB / 139737 bytes)") cath <## "use /fr 1 [/ | ] to receive it" cath <# "bob> -> forwarded" @@ -824,15 +825,15 @@ testMultiForwardFiles = cath <## "" -- file transfer - bob <## "completed uploading file 5 (test_1.jpg) for cath" + bob <## ("completed uploading file 5 (" <> jpgFileName <> ") for cath") bob <## "completed uploading file 6 (test_1.pdf) for cath" cath ##> "/fr 1" cath - <### [ "saving file 1 from bob to test_1.jpg", - "started receiving file 1 (test_1.jpg) from bob" + <### [ ConsoleString $ "saving file 1 from bob to " <> jpgFileName, + ConsoleString $ "started receiving file 1 (" <> jpgFileName <> ") from bob" ] - cath <## "completed receiving file 1 (test_1.jpg) from bob" + cath <## ("completed receiving file 1 (" <> jpgFileName <> ") from bob") cath ##> "/fr 2" cath @@ -841,9 +842,9 @@ testMultiForwardFiles = ] cath <## "completed receiving file 2 (test_1.pdf) from bob" - src1B <- B.readFile "./tests/tmp/bob_app_files/test_1.jpg" + src1B <- B.readFile ("./tests/tmp/bob_app_files/" <> jpgFileName) src1B `shouldBe` dest1 - dest1C <- B.readFile "./tests/tmp/cath_app_files/test_1.jpg" + dest1C <- B.readFile ("./tests/tmp/cath_app_files/" <> jpgFileName) dest1C `shouldBe` src1B src2B <- B.readFile "./tests/tmp/bob_app_files/test_1.pdf" @@ -886,5 +887,5 @@ testMultiForwardFiles = checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do bob ##> "/clear alice" bob <## "alice: all messages are removed locally ONLY" - fwdFileExists <- doesFileExist "./tests/tmp/bob_app_files/test_1.jpg" + fwdFileExists <- doesFileExist ("./tests/tmp/bob_app_files/" <> jpgFileName) fwdFileExists `shouldBe` True diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a185b0038e..b9fee913d8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} @@ -16,22 +17,26 @@ import Control.Monad (forM_, void, when) import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport +import Test.Hspec hiding (it) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) import System.Directory (copyFile) import System.FilePath (()) -import Test.Hspec hiding (it) +#endif chatGroupTests :: SpecWith FilePath chatGroupTests = do @@ -104,10 +109,13 @@ chatGroupTests = do it "group link without contact - known group" testPlanGroupLinkNoContactKnown it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting it "group link without contact - connecting (slow handshake)" testPlanGroupLinkNoContactConnectingSlow +#if !defined(dbPostgres) + -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet it "synchronize ratchets, reset connection code" testGroupSyncRatchetCodeReset +#endif describe "group message reactions" $ do it "set group message reactions" testSetGroupMessageReactions describe "group delivery receipts" $ do @@ -175,6 +183,8 @@ chatGroupTests = do it "can't repeat block, unblock" testBlockForAllCantRepeat describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive + describe "group member reports" $ do + it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -3547,6 +3557,7 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob ##> ("/c " <> gLink) bob <## "group link: connecting to group #team" +#if !defined(dbPostgres) testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -3595,8 +3606,8 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where copyDb from to = do - copyFile (chatStoreFile $ tmp from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testGroupSyncRatchet :: HasCallStack => FilePath -> IO () testGroupSyncRatchet tmp = @@ -3690,6 +3701,7 @@ testGroupSyncRatchetCodeReset tmp = connVerified | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#endif testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () testSetGroupMessageReactions = @@ -6540,3 +6552,80 @@ testGroupMemberInactive tmp = do { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] } } + +testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "jokes" alice bob cath + alice ##> "/mr jokes bob moderator" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of bob from admin to moderator", + bob <## "#jokes: alice changed your role from admin to moderator", + cath <## "#jokes: alice changed the role of bob from admin to moderator" + ] + alice ##> "/mr jokes cath member" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of cath from admin to member", + bob <## "#jokes: alice changed the role of cath from admin to member", + cath <## "#jokes: alice changed your role from admin to member" + ] + alice ##> "/create link #jokes" + gLink <- getGroupLink alice "jokes" GRMember True + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "dan (Daniel): accepting request to join group #jokes..." + alice <## "#jokes: dan joined the group", + do + dan <## "#jokes: joining the group..." + dan <## "#jokes: you joined the group" + dan <### + [ "#jokes: member bob (Bob) is connected", + "#jokes: member cath (Catherine) is connected" + ], + do + bob <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + bob <## "#jokes: new member dan is connected", + do + cath <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + cath <## "#jokes: new member dan is connected" + ] + cath #> "#jokes inappropriate joke" + concurrentlyN_ + [ alice <# "#jokes cath> inappropriate joke", + bob <# "#jokes cath> inappropriate joke", + dan <# "#jokes cath> inappropriate joke" + ] + dan ##> "/report #jokes content inappropriate joke" + dan <# "#jokes > cath inappropriate joke" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath inappropriate joke" + alice <## " report content", + do + bob <# "#jokes dan> > cath inappropriate joke" + bob <## " report content", + (cath ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) + alice ##> "\\\\ #jokes cath inappropriate joke" + concurrentlyN_ + [ do + alice <## "#jokes: 1 messages deleted by member alice" + alice <## "message marked deleted by you", + do + bob <# "#jokes cath> [marked deleted by alice] inappropriate joke" + bob <## "#jokes: 1 messages deleted by member alice", + cath <# "#jokes cath> [marked deleted by alice] inappropriate joke", + do + dan <# "#jokes cath> [marked deleted by alice] inappropriate joke" + dan <## "#jokes: 1 messages deleted by member alice" + ] + alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content [marked deleted by alice]")]) diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index c17b893be1..594e6e47b0 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -33,7 +33,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/contacts" -- not a contact - alice /* "keep in mind" + alice >* "keep in mind" alice ##> "/tail" alice <# "* keep in mind" alice ##> "/chats" @@ -50,7 +50,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail" alice ##> "/chats" - alice /* "ahoy!" + alice >* "ahoy!" alice ##> "/_update item *1 2 text Greetings." alice ##> "/tail *" alice <# "* Greetings." @@ -59,7 +59,7 @@ testUserNotes :: FilePath -> IO () testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "keep in mind" + alice >* "keep in mind" alice ##> "/tail" alice <# "* keep in mind" @@ -78,9 +78,9 @@ testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice - createCCNoteFolder alice tsS <- iso8601Show <$> getCurrentTime - alice /* "first" + alice >* "first" tsM <- iso8601Show <$> getCurrentTime - alice /* "last" + alice >* "last" tsE <- iso8601Show <$> getCurrentTime -- there's only one folder that got updated after tsM and before tsE @@ -95,10 +95,10 @@ testChatPagination :: FilePath -> IO () testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "hello world" - alice /* "memento mori" - alice /* "knock-knock" - alice /* "who's there?" + alice >* "hello world" + alice >* "memento mori" + alice >* "knock-knock" + alice >* "who's there?" alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")]) @@ -184,7 +184,7 @@ testOtherFiles = ] bob <## "completed receiving file 1 (test.jpg) from alice" - bob /* "test" + bob >* "test" bob ##> "/tail *" bob <# "* test" bob ##> "/clear *" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 71edbb93b0..36fe576dcb 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -17,6 +18,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options +import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -75,6 +77,8 @@ chatProfileTests = do describe "contact aliases" $ do it "set contact alias" testSetAlias it "set connection alias" testSetConnectionAlias + describe "group aliases" $ do + it "set group alias" testSetGroupAlias describe "pending connection users" $ do it "change user for pending connection" testChangePCCUser it "change from incognito profile connects as new user" testChangePCCUserFromIncognito @@ -1120,16 +1124,20 @@ testPlanAddressContactViaAddress = bob ##> ("/c " <> cLink) connecting alice bob - bob ##> "/_delete @2 notify=off" + bob ##> "/delete @alice" bob <## "alice: contact is deleted" - alice ##> "/_delete @2 notify=off" + alice ##> "/delete @bob" alice <## "bob: contact is deleted" void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile bob @@@ [("@alice", "")] -- GUI api +#if defined(dbPostgres) + bob ##> "/_connect contact 1 4" +#else bob ##> "/_connect contact 1 2" +#endif connecting alice bob where connecting alice bob = do @@ -1263,9 +1271,10 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil \alice bob cath -> do alice ##> "/ad" cLink <- getContactLink alice True + -- GUI /_accept api bob ##> ("/c " <> cLink) alice <#? bob - alice ##> "/accept incognito bob" + alice ##> "/_accept incognito=on 1" alice <## "bob (Bob): accepting contact request, you can send messages to contact" aliceIncognitoBob <- getTermLine alice concurrentlyN_ @@ -1290,10 +1299,10 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil alice ##> "/contacts" (alice ("/c " <> cLink) alice <#? cath - alice ##> "/_accept incognito=on 1" + alice ##> "/accept incognito cath" alice <## "cath (Catherine): accepting contact request, you can send messages to contact" aliceIncognitoCath <- getTermLine alice concurrentlyN_ @@ -1971,6 +1980,20 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" +testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias = testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + threadDelay 1500000 + alice ##> "/_set alias #1 friends" + alice <## "group #team alias updated: friends" + alice ##> "/groups" + alice <## "#team (2 members) (alias: friends)" + alice ##> "/_set alias #1" + alice <## "group #team alias removed" + alice ##> "/groups" + alice <## "#team (2 members)" + testSetContactPrefs :: HasCallStack => FilePath -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -2565,7 +2588,7 @@ testSetUITheme = a <## "you've shared main profile with this contact" a <## "connection not verified, use /code command to see security code" a <## "quantum resistant end-to-end encryption" - a <## "peer chat protocol version range: (Version 1, Version 11)" + a <## ("peer chat protocol version range: (Version 1, " <> show currentChatVersion <> ")") groupInfo a = do a <## "group ID: 1" a <## "current members: 1" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index ce820b12a7..24178125ee 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -21,7 +22,6 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol @@ -32,8 +32,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String @@ -45,6 +45,11 @@ import System.Info (os) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec import UnliftIO (timeout) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif defaultPrefs :: Maybe Preferences defaultPrefs = Just $ toChatPrefs defaultChatPrefs @@ -362,8 +367,8 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -(/*) :: HasCallStack => TestCC -> String -> IO () -cc /* note = do +(>*) :: HasCallStack => TestCC -> String -> IO () +cc >* note = do cc `send` ("/* " <> note) (dropTime <$> getTermLine cc) `shouldReturn` ("* " <> note) diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs new file mode 100644 index 0000000000..32e90cf754 --- /dev/null +++ b/tests/JSONFixtures.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE OverloadedStrings #-} + +module JSONFixtures where + +import qualified Data.ByteString.Lazy.Char8 as LB + +noActiveUserSwift :: LB.ByteString +noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" + +noActiveUserTagged :: LB.ByteString +noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + +activeUserExistsSwift :: LB.ByteString +activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + +activeUserExistsTagged :: LB.ByteString +activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + +activeUserSwift :: LB.ByteString +activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + +activeUserTagged :: LB.ByteString +activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + +chatStartedSwift :: LB.ByteString +chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" + +chatStartedTagged :: LB.ByteString +chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" + +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" + +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" + +userJSON :: LB.ByteString +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" + +memberSubSummarySwift :: LB.ByteString +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + +memberSubSummaryTagged :: LB.ByteString +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" + +userContactSubSummarySwift :: LB.ByteString +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + +userContactSubSummaryTagged :: LB.ByteString +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" + +pendingSubSummarySwift :: LB.ByteString +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + +pendingSubSummaryTagged :: LB.ByteString +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" + +parsedMarkdownSwift :: LB.ByteString +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" + +parsedMarkdownTagged :: LB.ByteString +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index a17a69fae8..400db87b36 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -12,7 +12,7 @@ import Data.ByteString.Builder (toLazyByteString) import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) -import MobileTests +import JSONFixtures import Simplex.Chat.Remote.Protocol (owsf2tagged) import Simplex.Messaging.Parsers import Test.Hspec diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 5f477d55ed..755ff8db26 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -26,15 +26,18 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) +import JSONFixtures import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC +import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Interface +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -79,12 +82,6 @@ noActiveUser = noActiveUserTagged #endif -noActiveUserSwift :: LB.ByteString -noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" - -noActiveUserTagged :: LB.ByteString -noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" - activeUserExists :: LB.ByteString activeUserExists = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -93,12 +90,6 @@ activeUserExists = activeUserExistsTagged #endif -activeUserExistsSwift :: LB.ByteString -activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" - -activeUserExistsTagged :: LB.ByteString -activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" - activeUser :: LB.ByteString activeUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -107,12 +98,6 @@ activeUser = activeUserTagged #endif -activeUserSwift :: LB.ByteString -activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" - -activeUserTagged :: LB.ByteString -activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" - chatStarted :: LB.ByteString chatStarted = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -121,12 +106,6 @@ chatStarted = chatStartedTagged #endif -chatStartedSwift :: LB.ByteString -chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" - -chatStartedTagged :: LB.ByteString -chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" - networkStatuses :: LB.ByteString networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -135,12 +114,6 @@ networkStatuses = networkStatusesTagged #endif -networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" - -networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" - memberSubSummary :: LB.ByteString memberSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -149,12 +122,6 @@ memberSubSummary = memberSubSummaryTagged #endif -memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" - -memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" - userContactSubSummary :: LB.ByteString userContactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -163,12 +130,6 @@ userContactSubSummary = userContactSubSummaryTagged #endif -userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" - -userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" - pendingSubSummary :: LB.ByteString pendingSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -177,15 +138,6 @@ pendingSubSummary = pendingSubSummaryTagged #endif -pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" - -pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" - -userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" - parsedMarkdown :: LB.ByteString parsedMarkdown = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -194,12 +146,6 @@ parsedMarkdown = parsedMarkdownTagged #endif -parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" - -parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" - testChatApiNoUser :: FilePath -> IO () testChatApiNoUser tmp = do let dbPrefix = tmp "1" @@ -213,8 +159,8 @@ testChatApiNoUser tmp = do testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" - f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" False MCYesUp + f = dbPrefix <> chatSuffix + Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" @@ -223,7 +169,6 @@ testChatApi tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses - chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 523df81ade..aba67ed034 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-11\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -182,6 +182,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) ) + it "x.msg.new report" $ + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + ##==## ChatMessage + chatInitialVRange + (Just $ SharedMsgId "\1\2\3\4") + (XMsgNew (MCQuote quotedMsg (extMsgContent (MCReport "" RRSpam) Nothing))) it "x.msg.new forward with file" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -243,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -264,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e51a938252..dd4032e274 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -12,10 +12,10 @@ import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.Map.Strict as M -import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File +import Simplex.Chat.Remote (remoteFilesFolder) import Simplex.Chat.Remote.Types import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String (strEncode) @@ -214,7 +214,7 @@ remoteStoreFileTest = rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath remoteFilesFolder _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -338,7 +338,7 @@ remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath remoteFilesFolder _ -> fail "Host session 1 should be started" mobileName <- userName mobile diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index d13dc94b63..307e715dfb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,8 +10,8 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore, createSQLiteStore) -import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.Interface +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) @@ -22,7 +22,7 @@ testDB :: FilePath testDB = "tests/tmp/test_chat.db" appSchema :: FilePath -appSchema = "src/Simplex/Chat/Migrations/chat_schema.sql" +appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" -- Some indexes found by `.lint fkey-indexes` are not added to schema, explanation: -- @@ -38,7 +38,7 @@ appSchema = "src/Simplex/Chat/Migrations/chat_schema.sql" -- EXPLAIN QUERY PLAN DELETE FROM group_members; -- (uses idx_connections_group_member) appLint :: FilePath -appLint = "src/Simplex/Chat/Migrations/chat_lint.sql" +appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -53,7 +53,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore testDB "" False MCError + void $ createChatStore (DBOpts testDB "" False True) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,16 +61,16 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore testDB "" False MCError + void $ createChatStore (DBOpts testDB "" False True) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createSQLiteStore testDB "" False noDownMigrations MCError + Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations - closeSQLiteStore st + closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where @@ -78,14 +78,14 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - Migrations.run st $ MTRUp [m] + Migrations.run st True $ MTRUp [m] schema' <- getSchema testDB testSchema schema' `shouldNotBe` schema - Migrations.run st $ MTRDown [downMigr] + Migrations.run st True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - Migrations.run st $ MTRUp [m] + Migrations.run st True $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' diff --git a/tests/Test.hs b/tests/Test.hs index 5286dced82..e4485e68fc 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE CPP #-} + import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -9,39 +11,56 @@ import JSONTests import LinkPreviewTests import MarkdownTests import MessageBatching -import MobileTests import ProtocolTests import OperatorTests import RandomServers import RemoteTests -import SchemaDump import Test.Hspec hiding (it) import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests +#if defined(dbPostgres) +import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) +#else +import MobileTests +import SchemaDump import WebRTCTests +#endif main :: IO () main = do - setLogLevel LogDebug - withGlobalLogging logCfg . hspec $ do - describe "Schema dump" schemaDumpTest - describe "SimpleX chat markdown" markdownTests - describe "JSON Tests" jsonTests - describe "SimpleX chat view" viewTests - describe "SimpleX chat protocol" protocolTests - around tmpBracket $ describe "WebRTC encryption" webRTCTests - describe "Valid names" validNameTests - describe "Message batching" batchingTests - describe "Operators" operatorTests - describe "Random servers" randomServersTests - around testBracket $ do - describe "Mobile API Tests" mobileTests - describe "SimpleX chat client" chatTests - xdescribe'' "SimpleX Broadcast bot" broadcastBotTests - xdescribe'' "SimpleX Directory service bot" directoryServiceTests - describe "Remote session" remoteTests - around testBracket $ describe "Link preview" linkPreviewTests + setLogLevel LogError + withGlobalLogging logCfg . hspec +#if defined(dbPostgres) + . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + . afterAll_ (dropDatabaseAndUser testDBConnectInfo) +#endif + $ do +-- TODO [postgres] schema dump for postgres +#if !defined(dbPostgres) + describe "Schema dump" schemaDumpTest + around tmpBracket $ describe "WebRTC encryption" webRTCTests +#endif + describe "SimpleX chat markdown" markdownTests + describe "JSON Tests" jsonTests + describe "SimpleX chat view" viewTests + describe "SimpleX chat protocol" protocolTests + describe "Valid names" validNameTests + describe "Message batching" batchingTests + describe "Operators" operatorTests + describe "Random servers" randomServersTests + around testBracket +#if defined(dbPostgres) + . after_ (dropAllSchemasExceptSystem testDBConnectInfo) +#endif + $ do +#if !defined(dbPostgres) + describe "Mobile API Tests" mobileTests +#endif + describe "SimpleX chat client" chatTests + xdescribe'' "SimpleX Broadcast bot" broadcastBotTests + xdescribe'' "SimpleX Directory service bot" directoryServiceTests + describe "Remote session" remoteTests where testBracket test = withSmpServer $ tmpBracket test tmpBracket test = do