diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7ac10b9cf..2338258d82 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/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 833c6445e5..3fd09f5f70 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -118,7 +118,7 @@ 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 } } @@ -147,19 +147,23 @@ 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) + } } } } @@ -190,6 +194,11 @@ class ChatTagsModel: ObservableObject { } } } + + func changeGroupReportsTag(_ by: Int = 0) { + if by == 0 { return } + presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by + } } class NetworkModel: ObservableObject { @@ -436,7 +445,7 @@ final class ChatModel: ObservableObject { updateChatInfo(cInfo) } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) - ChatTagsModel.shared.addPresetChatTags(cInfo) + ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats()) } } @@ -877,6 +886,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 @@ -960,7 +990,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) } } } @@ -999,6 +1030,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) + } + } + } } protocol ChatItemsChangesListener { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9665687e83..0d763e367c 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 { @@ -459,6 +453,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( @@ -845,6 +851,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", @@ -1985,6 +2003,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) } @@ -2048,6 +2069,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/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 59e21fa6c0..9ead14ab6c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -963,6 +963,7 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var actionSheet: SomeActionSheet? = nil var reveal: (Bool) -> Void @@ -1072,6 +1073,7 @@ struct ChatView: View { } } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { @@ -1284,7 +1286,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) } @@ -1358,7 +1360,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 @@ -1408,8 +1415,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 { @@ -1683,7 +1694,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), @@ -1705,10 +1716,7 @@ struct ChatView: View { deletingItem = ci } } label: { - Label( - NSLocalizedString("Delete", comment: "chat item action"), - systemImage: "trash" - ) + Label(label, systemImage: "trash") } } @@ -1727,10 +1735,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) @@ -1744,6 +1752,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 { @@ -1783,7 +1809,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?" @@ -1844,11 +1901,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)") } } } @@ -1921,6 +1982,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() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 1989557798..15823b9514 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) @@ -691,6 +724,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 { @@ -720,6 +774,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() } } @@ -751,6 +814,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 { @@ -877,6 +942,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) } @@ -896,7 +963,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/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index f449ccc1a4..f985356289 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 36a98e3f2f..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,405 +559,6 @@ struct ChatListNavLink: View { } } -struct TagEditorNavParams { - let chat: Chat? - let chatListTag: ChatTagData? - let tagId: Int64? -} - -struct ChatListTag: 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 { - 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 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 ChatListTagEditor: 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" - : tagId == nil - ? "Create 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 { 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 edf9a3e5d2..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) + 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..(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 babcbae806..17f924f344 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.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.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 */; }; @@ -203,9 +203,11 @@ 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; }; 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; }; 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; }; + 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; }; 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 */; }; @@ -520,9 +522,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.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.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 = ""; }; @@ -555,9 +557,11 @@ 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = ""; }; 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.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 = ""; }; @@ -679,9 +683,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -765,8 +769,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */, ); path = Libraries; sourceTree = ""; @@ -954,6 +958,7 @@ 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, + 8CBC14852D357CDB00BBD901 /* StorageView.swift */, ); path = UserSettings; sourceTree = ""; @@ -971,6 +976,7 @@ 18415835CBD939A9ABDC108A /* UserPicker.swift */, 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */, E51CC1E52C62085600DB91FE /* OneHandUICard.swift */, + B70A39722D24090D00E80A5F /* TagListView.swift */, ); path = ChatList; sourceTree = ""; @@ -1466,6 +1472,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 */, @@ -1537,6 +1544,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 */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 2e38087ab3..36f1bdaeab 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]) @@ -221,6 +222,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: ","))" @@ -390,6 +393,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" @@ -646,6 +650,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) @@ -825,6 +830,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" @@ -1004,6 +1010,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") @@ -2225,6 +2233,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 @@ -2489,6 +2513,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) @@ -2505,11 +2530,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 @@ -2648,6 +2690,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 @@ -2683,6 +2726,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 } @@ -2719,6 +2763,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..b3516e068f 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-privacy-preserving-content-moderation.html")! + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1533,13 +1541,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 } @@ -1695,7 +1706,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 +1718,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 +1776,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, @@ -2008,6 +2015,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 +2110,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 +2118,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 +2149,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 +2169,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 +2193,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 +2604,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 +2704,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 +3026,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 +3171,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 +3346,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 +3415,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 +3686,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 +3694,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 +3704,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 +3726,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 +3738,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 +3798,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3726,6 +3809,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 +3845,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 +3886,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 +3969,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) { diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 8b0d082aed..2341eb4a4f 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 89cc45c4f5..be43158bc1 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -267,17 +267,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/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index a5a1726757..995b584fce 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -99,7 +99,8 @@ class CallActivity: ComponentActivity(), ServiceConnection { fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) { // By manually specifying source rect we exclude empty background while toggling PiP val builder = PictureInPictureParams.Builder() - .setAspectRatio(viewRatio) + // that's limitation of Android. Otherwise, may crash on devices like Z Fold 3 + .setAspectRatio(viewRatio?.coerceIn(Rational(100, 239)..Rational(239, 100))) .setSourceRectHint(sourceRectHint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(video) 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/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/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/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 e2fe96e178..e2fd922b34 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,11 @@ 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 @@ -23,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 @@ -59,28 +58,20 @@ 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()) @@ -157,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 @@ -177,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 { @@ -209,7 +229,7 @@ object ChatModel { for (chat in chats.value.filter { it.remoteHostId == rhId }) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chat.chatInfo)) { + if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) { newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 } } @@ -249,17 +269,17 @@ object ChatModel { } } - fun addPresetChatTags(chatInfo: ChatInfo) { + private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { presetTags[tag] = (presetTags[tag] ?: 0) + 1 } } } - fun removePresetChatTags(chatInfo: ChatInfo) { + fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { val count = presetTags[tag] if (count != null) { presetTags[tag] = maxOf(0, count - 1) @@ -268,27 +288,6 @@ object ChatModel { } } - fun markChatTagRead(chat: Chat) { - if (chat.unreadTag) { - chat.chatInfo.chatTags?.let { tags -> - markChatTagRead_(chat, tags) - } - } - } - - fun updateChatTagRead(chat: Chat, wasUnread: Boolean) { - 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) { - markChatTagRead_(chat, tags) - } - } - fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { if (chat.unreadTag) { oldTags?.forEach { t -> @@ -303,45 +302,59 @@ object ChatModel { } } } - - private fun markChatTagRead_(chat: Chat, tags: List) { - for (tag in tags) { - val count = unreadTags[tag] - if (count != null) { - unreadTags[tag] = maxOf(0, count - 1) - } - } - } - - // 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 // 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) @@ -380,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) @@ -397,7 +417,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) - addPresetChatTags(cInfo) + addPresetChatTags(cInfo, Chat.ChatStats()) } } @@ -458,7 +478,7 @@ object ChatModel { else chat.chatStats ) - updateChatTagRead(chats[i], wasUnread) + updateChatTagReadNoContentTag(chats[i], wasUnread) if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) @@ -474,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) } } } @@ -495,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 @@ -521,7 +541,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci) + chatItems.addAndNotify(ci, contentTag) true } } else { @@ -546,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) @@ -585,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() @@ -597,7 +617,7 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats { + withChats(contentTag) { chats.replaceAll(popCollectedChats()) } } @@ -635,11 +655,10 @@ 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 @@ -650,12 +669,47 @@ object ChatModel { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) - updateChatTagRead(chats[chatIdx], wasUnread) + 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 @@ -668,21 +722,15 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagRead(chats[chatIndex], wasUnread) + updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - var removed: ChatInfo? = null - chats.removeAll { - val found = it.id == id && it.remoteHostId == rhId - if (found) { - removed = it.chatInfo - } - found - } - - removed?.let { - removePresetChatTags(it) + val i = getChatIndex(rhId, id) + if (i != -1) { + val chat = chats.removeAt(i) + removePresetChatTags(chat.chatInfo, chat.chatStats) + removeWallpaperFilesFromChat(chat) } } @@ -694,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 && @@ -710,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 { @@ -731,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 @@ -762,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 @@ -851,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++ @@ -866,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) { @@ -891,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() @@ -1137,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( @@ -1334,7 +1434,13 @@ sealed class ChatInfo: SomeChat, NamedChat { 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 { @@ -1530,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) @@ -1655,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, @@ -1798,7 +1903,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 { @@ -1849,13 +1954,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) } @@ -2276,6 +2387,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 @@ -2492,8 +2611,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) { @@ -2504,8 +2623,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) { @@ -2534,7 +2653,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) } } @@ -2546,7 +2666,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) @@ -2554,7 +2674,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) { @@ -2568,7 +2688,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() @@ -2696,7 +2817,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" @@ -2928,6 +3049,7 @@ sealed class CIForwardedFrom { @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), + @SerialName("internalMark") cidmInternalMark("internalMark"), @SerialName("broadcast") cidmBroadcast("broadcast"); } @@ -3129,6 +3251,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) } } @@ -3175,9 +3305,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 @@ -3453,15 +3585,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 } } @@ -3476,6 +3615,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() = @@ -3552,6 +3692,10 @@ object MsgContentSerializer : KSerializer { element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) + element("MCReport", buildClassSerialDescriptor("MCReport") { + element("text") + element("reason") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -3582,6 +3726,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 { @@ -3630,12 +3778,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 @@ -3724,6 +3889,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() {} 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 a86be622b9..b42d99f2fc 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, @@ -105,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 { @@ -373,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" @@ -683,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}") @@ -894,8 +898,8 @@ object ChatController { return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + 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) { @@ -976,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 @@ -1400,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 -> { @@ -1487,6 +1511,9 @@ object ChatController { withChats { clearChat(chat.remoteHostId, updatedChatInfo) } + withChats(MsgContentTag.Report) { + clearChat(chat.remoteHostId, updatedChatInfo) + } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() } @@ -2392,7 +2419,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())) @@ -2402,7 +2429,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) @@ -2414,10 +2441,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 } @@ -2462,9 +2492,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 @@ -2487,6 +2527,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 -> @@ -2496,13 +2541,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 @@ -2531,6 +2583,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 -> { @@ -2596,30 +2707,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)) { @@ -2992,6 +3118,11 @@ object ChatController { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { + if (cItem.isReport) { + upsertChatItem(rh, cInfo, cItem) + } + } } } @@ -3001,10 +3132,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() + } } } @@ -3044,8 +3179,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() } @@ -3155,8 +3295,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()}") + } } } } @@ -3191,7 +3335,7 @@ sealed class 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() @@ -3200,6 +3344,7 @@ sealed class 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() @@ -3352,7 +3497,14 @@ sealed class CC { 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) @@ -3368,6 +3520,7 @@ sealed class CC { 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(",")}" @@ -3531,6 +3684,7 @@ sealed class CC { is ApiUpdateChatTag -> "apiUpdateChatTag" is ApiReorderChatTags -> "apiReorderChatTags" is ApiCreateChatItems -> "apiCreateChatItems" + is ApiReportMessage -> "apiReportMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" @@ -5523,6 +5677,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() @@ -5707,6 +5862,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" @@ -5809,7 +5965,7 @@ 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)}") @@ -5883,6 +6039,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") @@ -6612,6 +6769,7 @@ sealed class BrokerErrorType { @Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType() } +// ProtocolErrorType @Serializable sealed class SMPErrorType { val string: String get() = when (this) { @@ -6620,9 +6778,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" @@ -6633,6 +6792,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() @@ -6656,6 +6816,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) { @@ -6731,6 +6907,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" @@ -6746,6 +6923,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() @@ -6895,6 +7073,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, @@ -6903,6 +7088,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, @@ -6938,6 +7124,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 } @@ -6984,6 +7171,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) } @@ -7020,6 +7208,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, privacyProtectScreen = false, @@ -7057,6 +7246,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 9b580edb62..afff6a9561 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.* 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 c58561718e..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 @@ -31,8 +31,8 @@ import chat.simplex.common.model.CIDirection.GroupRcv 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.markChatTagRead 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.* @@ -57,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) { @@ -69,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()) { @@ -94,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 @@ -107,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 = { @@ -149,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 -> @@ -210,6 +238,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ) } }, + groupReports, + scrollToItemId, attachmentOption, attachmentBottomSheetState, searchText, @@ -220,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()) { @@ -229,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 @@ -241,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) { @@ -262,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 }) @@ -274,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) { @@ -286,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) @@ -294,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 @@ -334,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) + } + } } } } @@ -431,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 { @@ -447,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())) @@ -497,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( @@ -507,6 +578,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - itemsIds ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id, itemsIds) + } } }, markChatRead = { @@ -514,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( @@ -523,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(), @@ -550,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 -> { @@ -561,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 -> {} @@ -587,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, @@ -598,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, @@ -658,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 } @@ -671,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, @@ -680,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) } } } @@ -713,6 +851,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, + groupReports: State, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -734,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>() @@ -928,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, @@ -971,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 * */ @@ -983,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 @@ -1012,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) } ) @@ -1048,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), @@ -1073,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) } } @@ -1082,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) { @@ -1151,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 } @@ -1259,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) }) } } @@ -1273,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 @@ -1322,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 } @@ -1343,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 @@ -1359,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 { @@ -1383,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 * */ @@ -1423,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 @@ -1473,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 = { @@ -1495,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), @@ -1512,6 +1713,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1522,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 @@ -1531,6 +1733,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1543,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 @@ -1557,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) } } @@ -1572,6 +1775,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1592,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 } @@ -1646,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 } } @@ -1661,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 { @@ -1750,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, @@ -1876,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 { @@ -1890,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 @@ -1922,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 { @@ -2026,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) @@ -2085,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) } } } @@ -2286,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 ) } @@ -2423,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, @@ -2434,8 +2679,9 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2496,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, @@ -2507,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..7ca5c873bd 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() @@ -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 } @@ -54,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, @@ -64,14 +66,17 @@ 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, 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) } @@ -195,6 +200,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe withChats { upsertGroupMember(rhId, groupInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } }, @@ -279,6 +287,7 @@ fun ModalData.GroupChatInfoLayout( members: List, developerTools: Boolean, groupLink: String?, + scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, @@ -306,12 +315,12 @@ 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( @@ -355,6 +364,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 { @@ -484,6 +500,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 { @@ -734,6 +759,7 @@ fun PreviewGroupChatInfoLayout() { members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, 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 e607efeddc..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 @@ -28,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.* @@ -65,6 +66,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -83,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() } @@ -98,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()) } @@ -142,6 +146,9 @@ fun GroupMemberInfoView( withChats { upsertGroupMember(rhId, groupInfo, mem) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, mem) + } }.onFailure { newRole.value = prevValue } @@ -157,6 +164,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -171,6 +181,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -188,6 +201,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -203,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 } @@ -246,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withChats { upsertGroupMember(rhId, groupInfo, removedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, removedMember) + } } close?.invoke() } @@ -753,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)) + } } } } @@ -786,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 2c16de40e9..dc51e5eec5 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,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -13,6 +14,8 @@ 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) @@ -235,6 +226,37 @@ 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-privacy-preserving-content-moderation.html" + +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, 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 647c74da06..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) { @@ -1133,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 2f0311b087..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 @@ -10,24 +10,17 @@ import dev.icerock.moko.resources.compose.stringResource 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.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* -import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle -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.ChatModel.markChatTagRead -import chat.simplex.common.model.ChatModel.updateChatTagRead 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.* @@ -71,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) @@ -89,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) @@ -107,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) } }, @@ -209,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 @@ -241,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() } @@ -251,7 +251,7 @@ 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) } @@ -291,7 +291,7 @@ fun GroupMenuItems( } else -> { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -312,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) } @@ -320,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 } @@ -351,14 +351,14 @@ fun TagListAction( ) { val userTags = remember { chatModel.userTags } ItemAction( - stringResource(MR.strings.list_menu), + 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) + TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false) } } showMenu.value = false @@ -562,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, @@ -608,7 +611,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - updateChatTagRead(chat, wasUnread) + updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -870,7 +873,9 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { - chatModel.updateChatTagRead(updatedChat, wasUnread) + withChats { + updateChatTagReadNoContentTag(updatedChat, wasUnread) + } } val current = currentState?.value if (current != null) { @@ -923,7 +928,8 @@ fun PreviewChatListNavLinkDirect() { disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, - progressByTimeout = false + progressByTimeout = false, + {} ) }, click = {}, @@ -968,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 4648ac5037..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 @@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS } +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -152,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, listStat if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { Divider() - TagsView() + TagsView(searchText) ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) - TagsView() + TagsView(searchText) Divider() } } @@ -925,35 +925,28 @@ private fun ChatListFeatureCards() { private val TAG_MIN_HEIGHT = 35.dp @Composable -private fun TagsView() { +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() - fun showTagList() { - ModalManager.start.showCustomModal { close -> - val editMode = remember { stateGetOrPut("editMode") { false } } - ModalView(close, showClose = true, endButtons = { - TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) { - Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) - } - }) { - TagListView(rhId = rhId, close = close, editMode = editMode) - } - } - } val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { - if (presetTags.size > 1) { - if (presetTags.size + userTags.value.size <= 3) { + 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() + CollapsedTagsFilterView(searchText) + alwaysShownPresetTags.forEach { tag -> + ExpandedTagFilterView(tag.key) + } } } @@ -963,69 +956,75 @@ private fun TagsView() { else -> false } val interactionSource = remember { MutableInteractionSource() } - 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) + 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 { + 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 + ) + } + 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) } - }, - onLongClick = { showTagList() }, - interactionSource = interactionSource, - indication = LocalIndication.current - ) - .onRightClick { showTagList() } - .padding(4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - if (tag.chatTagEmoji != null) { - ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) - } else { - 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 - ) - } - 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 = 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 + ) } - 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 @@ -1051,23 +1050,8 @@ private fun TagsView() { } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagsRow(content: @Composable() (() -> Unit)) { - if (appPlatform.isAndroid) { - Row( - modifier = Modifier - .padding(horizontal = 14.dp) - .horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - content() - } - } else { - FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } - } -} +expect fun TagsRow(content: @Composable() (() -> Unit)) @Composable private fun ExpandedTagFilterView(tag: PresetTagKind) { @@ -1076,12 +1060,12 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { is ActiveFilter.PresetTag -> af.tag == tag else -> false } - val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) val (icon, text) = presetTagLabel(tag, active) val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Row( - modifier = rowSizeModifier + modifier = Modifier + .sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clip(shape = CircleShape) .clickable { if (activeFilter.value == ActiveFilter.PresetTag(tag)) { @@ -1121,21 +1105,19 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { @Composable -private fun CollapsedTagsFilterView() { +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 -> af.tag + is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null else -> null } - val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) - Box(rowSizeModifier - .padding(vertical = 4.dp) + Box(Modifier .clip(shape = CircleShape) - .size(30.sp.toDp()) + .size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clickable { showMenu.value = true }, contentAlignment = Alignment.Center ) { @@ -1145,7 +1127,7 @@ private fun CollapsedTagsFilterView() { painterResource(icon), stringResource(text), Modifier.size(18.sp.toDp()), - tint = MaterialTheme.colors.secondary + tint = MaterialTheme.colors.primary ) } else { Icon( @@ -1155,20 +1137,26 @@ private fun CollapsedTagsFilterView() { ) } - DefaultDropdownMenu(showMenu = showMenu) { - if (selectedPresetTag != null) { + 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 = { - chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = { + searchText.value = TextFieldValue() + chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = {} + } showMenu.value = false } ) } PresetTagKind.entries.forEach { tag -> - if ((presetTags[tag] ?: 0) > 0) { - ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu) + if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) { + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } } @@ -1179,14 +1167,19 @@ private fun CollapsedTagsFilterView() { fun ItemPresetFilterAction( presetTag: PresetTagKind, active: Boolean, - showMenu: MutableState + 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 = { - chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = {} + } showMenu.value = false } ) @@ -1205,26 +1198,18 @@ fun filteredChats( } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() if (s.isEmpty()) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) } + chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) } 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, activeFilter) - } else { - cInfo.anyNameContains(s) - }) - is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited - } else { - cInfo.anyNameContains(s) + 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 } - 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 - } } } } @@ -1232,14 +1217,15 @@ fun filteredChats( private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { - is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo) + 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): Boolean = +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 @@ -1256,16 +1242,27 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = 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/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 2cd0c953c7..1b563e6d02 100644 --- 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 @@ -5,8 +5,7 @@ import SectionDivider import SectionItemView import TextIconSpaced import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.itemsIndexed @@ -33,6 +32,7 @@ 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 @@ -44,12 +44,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState = remember { mutableStateOf(false) }) { - if (remember { editMode }.value) { - BackHandler { - editMode.value = false - } - } +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() @@ -77,12 +72,12 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu val topPaddingToContent = topPaddingToContent(false) LazyColumnWithScrollBar( - modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier, + 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 ), - state = listState, verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, ) { @Composable fun CreateList() { @@ -97,7 +92,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu } } - if (oneHandUI.value && !editMode.value) { + if (oneHandUI.value && !reorderMode) { item { CreateList() } @@ -111,15 +106,14 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu backgroundColor = if (isDragging) colors.surface else Color.Unspecified ) { Column { - val showMenu = remember { mutableStateOf(false) } val selected = chatTagIds.value.contains(tag.chatTagId) Row( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) - .combinedClickable( - enabled = !saving.value, + .clickable( + enabled = !saving.value && !reorderMode, onClick = { if (chat == null) { ModalManager.start.showModalCloseable { close -> @@ -139,13 +133,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu }) } }, - onLongClick = if (editMode.value) null else { - { showMenu.value = true } - }, - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current ) - .onRightClick { showMenu.value = true } .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), verticalAlignment = Alignment.CenterVertically ) { @@ -163,21 +151,17 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu if (selected) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) - } else if (editMode.value) { + } else if (reorderMode) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } - DefaultDropdownMenu(showMenu, dropdownMenuItems = { - EditTagAction(rhId, tag, showMenu) - DeleteTagAction(rhId, tag, showMenu, saving) - }) } SectionDivider() } } } } - if (!oneHandUI.value && !editMode.value) { + if (!oneHandUI.value && !reorderMode) { item { CreateList() } @@ -279,7 +263,7 @@ fun ModalData.TagListEditor( SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { Text( - generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list), + generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list), color = if (disabled) colors.secondary else colors.primary ) } @@ -309,6 +293,15 @@ fun ModalData.TagListEditor( } } +@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( @@ -343,6 +336,21 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, 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) 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 28772f01d3..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.* @@ -528,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() } 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/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 39611361e3..5c4f9b631e 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 @@ -316,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 { 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/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 ffbe473df8..398648b666 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 @@ -294,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 @@ -308,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 @@ -319,6 +339,9 @@ Edit Info Search + Archive + Archive report + Delete report Sent message Received message History @@ -336,6 +359,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -421,8 +445,14 @@ Contacts Groups Businesses + Notes + Reports All Add list + 1 report + %d reports + Member reports + Archived member reports Share message… @@ -461,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 @@ -644,6 +679,7 @@ Create list Add to list + Change list Save list List name... List name and emoji should be different for all lists. @@ -651,6 +687,7 @@ Delete list? All chats will be removed from the list %s, and the list deleted Edit + Change order You invited a contact @@ -939,6 +976,7 @@ Show slow API calls Shutdown? Notifications will stop working until you re-launch the app + Error saving settings Create address @@ -1269,6 +1307,12 @@ Soft Medium Strong + Open links from chat list + Yes + No + Ask + Open web link? + Open link YOU @@ -1552,6 +1596,7 @@ observer author member + moderator admin owner 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/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/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/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/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 7f02a580e6..3017f82c8c 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -15,7 +15,7 @@ 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, @@ -23,13 +23,14 @@ data DirectoryOpts = DirectoryOpts superUsers :: [KnownContact], 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 @@ -58,6 +59,11 @@ 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, @@ -65,15 +71,16 @@ directoryOpts appDir defaultDbFileName = do superUsers, 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..102fe4cf7e 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 @@ -36,6 +37,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 +74,57 @@ 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} <- 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 pure opts +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, 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 -> 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 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 +174,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 +438,8 @@ 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 + 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 +467,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 () @@ -688,7 +709,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/20250114-simplex-network-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..a158fd517b --- /dev/null +++ b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md @@ -0,0 +1,22 @@ +--- +layout: layouts/article.html +title: "SimpleX network: privacy preserving content moderation" +date: 2025-01-14 +preview: How network operators prevent distribution of CSAM without compromising users privacy and security. +# image: images/20241218-pub.jpg +# imageWide: true +draft: true +permalink: "/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: privacy preserving content moderation + +**Will be published:** Jan 14, 2025 + +This blog post will cover our approach to removing CSAM that has: +- NO user identification, thus preserving privacy of the users. +- NO client- or server-side content scanning, thus preserving privacy and security of e2e encryption. + +The current and future content restriction will only be applied based on the users' complaints, and only to the content that can be accessed by server operators via public channels. + +Please read this document: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md diff --git a/cabal.project b/cabal.project index ee3fab10bd..5ae5c41937 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 184a95cd2a2806fb3d318508ca1f6528201a8114 + tag: dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d 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/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 0a68ec30d7..0000000000 --- a/package.yaml +++ /dev/null @@ -1,187 +0,0 @@ -name: simplex-chat -version: 6.3.0.0 -#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.* - -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/nix/sha256map.nix b/scripts/nix/sha256map.nix index b68a60ec9b..e3e39e3f47 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."184a95cd2a2806fb3d318508ca1f6528201a8114" = "0zlxi2dfv86sph9ndnazlvqgy0kq5rd1j6pjh41pswhaz8diwcag"; + "https://github.com/simplex-chat/simplexmq.git"."dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d" = "0rgnqqkvhgd0a3vncbfx75sqlnd6spgyi2nj303pifdh9hza7k57"; "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 eec13dcd85..e74d785336 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.3.0.0 +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 @@ -44,130 +48,12 @@ 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.M20241222_operator_conditions - Simplex.Chat.Migrations.M20241223_chat_tags - Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared - Simplex.Chat.Mobile.WebRTC 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 +71,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 +88,137 @@ 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.Mobile + Simplex.Chat.Mobile.WebRTC + 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 other-modules: Paths_simplex_chat hs-source-dirs: @@ -222,7 +238,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.* @@ -239,9 +254,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.* @@ -253,6 +267,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.* @@ -276,61 +300,12 @@ 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: - 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.* + base >=4.7 && <5 , 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 @@ -342,61 +317,21 @@ 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: - 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-broadcast-bot main-is: Main.hs @@ -411,61 +346,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: - 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 @@ -479,61 +375,24 @@ executable 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: 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.* - , 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 @@ -551,60 +410,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: - 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 @@ -623,18 +450,16 @@ test-suite simplex-chat-test ChatTests.Local ChatTests.Profiles ChatTests.Utils + JSONFixtures JSONTests MarkdownTests MessageBatching - MobileTests OperatorTests ProtocolTests RandomServers RemoteTests - SchemaDump ValidNames ViewTests - WebRTCTests Broadcast.Bot Broadcast.Options Directory.Events @@ -643,6 +468,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 @@ -659,60 +489,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 8d5e1fea8d..ce15d29022 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -20,7 +21,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,23 +32,28 @@ 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 import Simplex.Chat.Types -import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) +import Simplex.Chat.Util (shuffle) 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) import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) +#endif operatorSimpleXChat :: NewServerOperator operatorSimpleXChat = @@ -182,11 +187,20 @@ 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 dbOpts confirmMigrations = runExceptT $ do +#if defined(dbPostgres) + let ChatDbOpts {dbName, dbUser, dbSchemaPrefix} = dbOpts + connectInfo = defaultConnectInfo {connectUser = dbUser, connectDatabase = dbName} + chatStore <- ExceptT $ createChatStore connectInfo (chatSchema dbSchemaPrefix) confirmMigrations + agentStore <- ExceptT $ createAgentStore connectInfo (agentSchema dbSchemaPrefix) confirmMigrations pure ChatDatabase {chatStore, agentStore} +#else + let ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} = dbOpts + chatStore <- ExceptT $ createChatStore (chatStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration + agentStore <- ExceptT $ createAgentStore (agentStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration + pure ChatDatabase {chatStore, agentStore} +#endif newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController newChatController 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/Call.hs b/src/Simplex/Chat/Call.hs index 882ec8ccd0..3fd52e8493 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,9 +1,13 @@ +{-# LANGUAGE CPP #-} {-# 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,13 +22,19 @@ 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.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) import Simplex.Messaging.Util (decodeJSON, encodeJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data Call = Call { contactId :: ContactId, @@ -90,6 +100,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 +116,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..799c4ea1fc 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) @@ -346,7 +353,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 @@ -464,7 +471,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 +563,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 +590,6 @@ allowRemoteCommand = \case DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False - SlowSQLQueries -> False _ -> True data ChatResponse @@ -632,6 +641,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} @@ -794,7 +804,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 +827,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 +854,9 @@ allowRemoteEvent = \case CRRemoteCtrlConnected _ -> False CRRemoteCtrlStopped {} -> False CRSQLResult _ -> False +#if !defined(dbPostgres) CRSlowSQLQueries {} -> False +#endif _ -> True logResponseToFile :: ChatResponse -> Bool @@ -1171,11 +1185,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 +1518,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 +1601,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..8f6fdd8aff 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 @@ -47,14 +46,11 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMayb import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) 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 +83,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.Shared (upMigration) +import Simplex.Messaging.Agent.Store (execSQL) +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 +106,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 +115,20 @@ 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 Data.Time (NominalDiffTime, addUTCTime) +import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +#else +import Data.Bifunctor (bimap, first, second) +import qualified Data.ByteArray as BA +import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) +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 +146,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 @@ -444,6 +462,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 +472,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 +487,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 +499,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 +534,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 +564,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 +653,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 +669,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 +700,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 +821,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 +906,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 +944,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 @@ -1535,6 +1591,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 +1954,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 +1976,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 +2018,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 +2075,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 +2192,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 +2371,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 +2451,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 +2675,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 +2910,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 +2976,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 +2995,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 +3017,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 +3075,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}} = @@ -3516,6 +3590,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 +3600,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 +3614,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 +3624,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 <$> displayName <*> optional (" @" *> displayName) <*> _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), @@ -3588,7 +3666,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), @@ -3670,7 +3748,7 @@ chatCommandP = ("/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), + ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName <*> (" mute" $> MFNone <|> pure MFAll)), ("/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), @@ -3832,7 +3910,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), @@ -3902,6 +3979,7 @@ chatCommandP = A.choice [ " owner" $> GROwner, " admin" $> GRAdmin, + " moderator" $> GRModerator, " member" $> GRMember, " observer" $> GRObserver ] @@ -3960,9 +4038,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 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..d665ab806b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -38,8 +39,6 @@ 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 @@ -53,8 +52,15 @@ 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, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) @@ -91,14 +97,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 +316,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 +741,7 @@ aciFileStatusJSON = \case data FileError = FileErrAuth + | FileErrBlocked {server :: String, blockInfo :: BlockingInfo} | FileErrNoFile | FileErrRelay {srvError :: SrvError} | FileErrOther {fileError :: Text} @@ -747,14 +750,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..16bd749f30 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -17,13 +18,12 @@ 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.Protocol import Simplex.Chat.Types @@ -34,6 +34,13 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data MsgDirection = MDRcv | MDSnd deriving (Eq, Show) @@ -103,15 +110,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 4f39c5d6a0..8c332f9902 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -42,13 +42,15 @@ 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 import Simplex.Chat.Types 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.SQLite (closeDBStore, reopenSQLiteStore) +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) @@ -188,8 +190,12 @@ mobileChatOpts dbFilePrefix = ChatOpts { coreOptions = CoreChatOpts - { dbFilePrefix, - dbKey = "", -- for API database is already opened, and the key in options is not used + { dbOptions = + ChatDbOpts + { dbFilePrefix, + dbKey = "", -- for API database is already opened, and the key in options is not used + vacuumOnMigration = True + }, smpServers = [], xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, @@ -226,7 +232,7 @@ defaultMobileConfig = deviceNameForRemote = "Mobile" } -getActiveUser_ :: SQLiteStore -> IO (Maybe User) +getActiveUser_ :: DBStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) @@ -239,12 +245,13 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where + opts = mobileChatOpts dbFilePrefix initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) backgroundMode + newChatController db user_ defaultMobileConfig opts backgroundMode migrate createStore dbFile confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations) + (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ dbOptions $ coreOptions opts)) `catch` (pure . checkDBError) `catchAll` (pure . dbError) where @@ -255,8 +262,8 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept 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 diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 9eda85aaf3..6441d651da 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,8 +44,6 @@ 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.Types (User) @@ -55,6 +54,13 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTy import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif usageConditionsCommit :: Text usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" @@ -119,7 +125,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 @@ -300,22 +313,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 +351,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 +440,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 +458,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..1796baa5db --- /dev/null +++ b/src/Simplex/Chat/Options/DB.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE CPP #-} + +module Simplex.Chat.Options.DB +#if defined(dbPostgres) + ( module Simplex.Chat.Options.Postgres, + ) + where +import Simplex.Chat.Options.Postgres +#else + ( module Simplex.Chat.Options.SQLite, + ) + where +import Simplex.Chat.Options.SQLite +#endif diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs new file mode 100644 index 0000000000..635223152c --- /dev/null +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Options.Postgres where + +import Options.Applicative + +data ChatDbOpts = ChatDbOpts + { dbName :: String, + dbUser :: String, + dbSchemaPrefix :: String + } + +chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts +chatDbOptsP _appDir defaultDbName = do + dbName <- + strOption + ( long "database" + <> short 'd' + <> metavar "DB_NAME" + <> help "Database name" + <> value defaultDbName + <> showDefault + ) + dbUser <- + strOption + ( long "database-user" + <> short 'u' + <> metavar "DB_USER" + <> help "Database user" + <> value "simplex" + <> showDefault + ) + pure ChatDbOpts {dbName, dbUser, dbSchemaPrefix = ""} + +dbString :: ChatDbOpts -> String +dbString ChatDbOpts {dbName} = dbName diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs new file mode 100644 index 0000000000..dc81356784 --- /dev/null +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Options.SQLite where + +import Data.ByteArray (ScrubbedBytes) +import Options.Applicative +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" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 934f23007d..cf3e36820c 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,8 +1,11 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -42,13 +45,12 @@ 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.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 @@ -56,6 +58,13 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstTo import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif -- Chat version history: -- 1 - support chat versions in connections (9/1/2023) @@ -69,12 +78,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 +131,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 +225,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 +259,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 +281,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 +450,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 +491,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 +502,7 @@ instance StrEncoding MsgContentTag where MCVideo_ -> "video" MCFile_ -> "file" MCVoice_ -> "voice" + MCReport_ -> "report" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ @@ -470,6 +511,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 +522,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 +535,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 +545,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 +560,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 +575,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 +593,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 +657,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 +695,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 +711,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 +727,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 +736,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 8c7a0bc552..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, 3, 0, 0] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 0] +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..dbb932740c 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,49 @@ module Simplex.Chat.Store AutoAccept (..), createChatStore, migrations, -- used in tests +#if defined(dbPostgres) + chatSchema, + agentSchema, +#else chatStoreFile, agentStoreFile, +#endif 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.Shared (MigrationConfirmation, MigrationError) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..)) +import Simplex.Chat.Store.Postgres.Migrations +import Simplex.Messaging.Agent.Store.Postgres (createDBStore) +#else +import Data.ByteArray (ScrubbedBytes) +import Simplex.Chat.Store.SQLite.Migrations +import Simplex.Messaging.Agent.Store.SQLite (createDBStore) +#endif -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError SQLiteStore) -createChatStore dbPath key keepKey = createSQLiteStore dbPath key keepKey migrations +#if defined(dbPostgres) +createChatStore :: ConnectInfo -> String -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore connectInfo schema = createDBStore connectInfo schema migrations + +chatSchema :: String -> String +chatSchema "" = "chat_schema" +chatSchema prefix = prefix <> "_chat_schema" + +agentSchema :: String -> String +agentSchema "" = "agent_schema" +agentSchema prefix = prefix <> "_agent_schema" +#else +createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError DBStore) +createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations chatStoreFile :: FilePath -> FilePath chatStoreFile = (<> "_chat.db") agentStoreFile :: FilePath -> FilePath agentStoreFile = (<> "_agent.db") +#endif 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..d8c154f1e0 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 @@ -110,40 +117,42 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do |] (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)) = 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} 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, 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) 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..cd7a87b443 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 #-} @@ -93,20 +94,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 +167,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 +190,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, + -- 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 +226,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} @@ -342,31 +350,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 +475,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 +501,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 +645,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, + -- 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 +714,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 +724,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 +815,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 @@ -841,7 +853,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 = @@ -882,12 +894,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 +909,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 +957,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 +972,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 +1002,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 +1041,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 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..2e0fca19ca 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,6 +50,7 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, + getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, @@ -140,8 +142,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,16 +151,24 @@ 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 (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM +#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 -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)) +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 +182,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 +261,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, 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) liftIO $ bitraverse (addGroupChatTags db) pure gm where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -318,7 +327,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 @@ -386,7 +395,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 @@ -531,7 +540,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 @@ -631,24 +640,28 @@ getUserGroups db vr user@User {userId} = do 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, 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 (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 +760,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 +970,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 +1205,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 +1366,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 +1381,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, 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) mapM (bitraverse (addGroupChatTags db) pure) gm_ where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -1641,7 +1653,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 +1679,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 +1711,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 +1739,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 +1780,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 +1866,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 +1913,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 +1925,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 +2027,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 +2043,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 = @@ -2084,7 +2095,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 @@ -2114,7 +2125,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 +2177,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 +2246,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 +2255,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 diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..f10659bcf8 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, @@ -139,8 +141,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 +152,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 +206,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 +291,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 +413,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 +459,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 +475,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 +486,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 +510,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 +557,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 +571,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 +669,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 +774,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 +881,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 +977,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 +990,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 +1040,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 +1054,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 +1063,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 +1077,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 +1110,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 +1118,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 +1155,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 +1293,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 +1334,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 +1400,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 +1457,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 +1470,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 +1504,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 +1518,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 +1527,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 +1541,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 +1574,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 +1582,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 +1619,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 +1805,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 +1860,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 +1879,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 +1922,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 +1954,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 +1965,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 +1978,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 +2034,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 +2086,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 +2111,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 +2277,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 +2385,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 +2615,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 +2782,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 +2795,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 +2807,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 +2815,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 +2827,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 +2839,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 +2847,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 @@ -3005,7 +3016,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 +3031,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 +3062,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..ad9bbd65a4 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -0,0 +1,1012 @@ +{-# 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, + 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, + 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 013075841e..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,28 +618,23 @@ 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 now - _ -> 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 now CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where @@ -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,7 +666,7 @@ 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} now = @@ -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 @@ -716,7 +718,7 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition |] (Only operatorId) pure $ case operatorAcceptedConds_ of - Just (operatorCommit, acceptedAt_, autoAccept) + 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_ autoAccept @@ -772,23 +774,23 @@ acceptConditions db condId opIds acceptedAt = do 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) + 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, autoAccepted, acceptedAt, autoAccepted) - Just (Just _) -> - DB.execute - db - (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING") - (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) - Nothing -> - DB.execute - db - q - (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) + 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| @@ -825,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 55% rename from src/Simplex/Chat/Store/Migrations.hs rename to src/Simplex/Chat/Store/SQLite/Migrations.hs index 33f5c329dc..0126fc600f 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -1,127 +1,129 @@ {-# 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.M20241222_operator_conditions -import Simplex.Chat.Migrations.M20241223_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.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = @@ -241,7 +243,9 @@ schemaMigrations = ("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), ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), - ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags) + ("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) ] -- | 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/Migrations/M20241222_operator_conditions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs index c0c4304313..761d883ead 100644 --- a/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241222_operator_conditions where +module Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20241223_chat_tags.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs index a83be7549d..4c2d4d1745 100644 --- a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241223_chat_tags where +module Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) 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/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 99% rename from src/Simplex/Chat/Migrations/chat_schema.sql rename to src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 7ddbc84e4a..0601c9bbc0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -402,7 +402,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( @@ -961,3 +962,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..5b56b67704 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,37 +392,37 @@ 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) 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)) :. 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} @@ -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, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. 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, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. 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} 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_ 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..8e9fbf55f4 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 @@ -195,9 +204,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 +325,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 +343,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 @@ -415,12 +422,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 +464,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 +603,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 +688,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 +817,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 +1170,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 +1186,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 +1204,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 +1222,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 +1237,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 diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 8465caeee0..bc5eadac3a 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -29,14 +30,18 @@ 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.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, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data ChatFeature = CFTimedMessages @@ -678,7 +683,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 +709,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..b70ae81974 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -6,21 +7,27 @@ 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 Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (blobFieldDecoder) +import Simplex.Messaging.Util ((<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types.Util -import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Util ((<$?>)) +#endif 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 +35,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..460076649e 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -13,12 +14,17 @@ 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.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) import Simplex.Messaging.Util (decodeJSON, encodeJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data UITheme = UITheme { themeId :: Text, 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 04a3be148c..b73f720930 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]"] @@ -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 @@ -2215,9 +2221,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..0aa508994a 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -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 (()) @@ -67,11 +69,22 @@ directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", im mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts mkDirectoryOpts tmp superUsers = DirectoryOpts - { coreOptions = testCoreOpts {dbFilePrefix = tmp serviceDbPrefix}, + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> serviceDbPrefix} +#else + {dbFilePrefix = tmp serviceDbPrefix} +#endif + + }, adminUsers = [], superUsers, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", + runCLI = False, searchResults = 3, testing = True } diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 585ef70f6e..1c107d5d3f 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 (closeStore) +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,31 @@ 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) +testDBName :: String +testDBName = "test_chat_db" + +testDBUser :: String +testDBUser = "test_chat_user" + +testDBConnectInfo :: ConnectInfo +testDBConnectInfo = + defaultConnectInfo { + connectUser = testDBUser, + connectDatabase = testDBName + } +#endif serverPort :: ServiceName serverPort = "7001" @@ -92,9 +111,20 @@ testOpts = testCoreOpts :: CoreChatOpts testCoreOpts = CoreChatOpts - { dbFilePrefix = "./simplex_v1", - dbKey = "", - -- dbKey = "this is a pass-phrase to encrypt the database", + { + dbOptions = ChatDbOpts +#if defined(dbPostgres) + { dbName = testDBName, + dbUser = testDBUser, + -- 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 +138,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 = @@ -246,18 +278,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 +323,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeSQLiteStore chatStore + closeStore 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..917fc36395 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -21,7 +22,6 @@ 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 @@ -29,19 +29,24 @@ import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options 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 Simplex.Chat.Store (agentStoreFile, chatStoreFile) +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 @@ -142,10 +149,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 +1241,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 +1373,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 +1491,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 +1539,7 @@ testDatabaseEncryption tmp = do alice <## "ok" withTestChat tmp "alice" $ \alice -> do testChatWorking alice bob +#endif testSubscribeAppNSE :: HasCallStack => FilePath -> IO () testSubscribeAppNSE tmp = @@ -2730,6 +2743,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 @@ -2867,6 +2881,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..b739b40b7d 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.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 Simplex.Chat.Store (agentStoreFile, chatStoreFile) 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 @@ -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..699565af23 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 (..)) @@ -1120,16 +1122,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 +1269,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 +1297,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_ @@ -2565,7 +2572,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 638d3d8078..730b0d8649 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -26,6 +26,7 @@ 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 @@ -34,7 +35,7 @@ import Simplex.Chat.Mobile.WebRTC 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.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 +80,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 +88,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 +96,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 +104,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 +112,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 +120,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 +128,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 +136,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 +144,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" @@ -214,7 +158,7 @@ testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" False MCYesUp + Right st <- createChatStore f "myKey" False MCYesUp True Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" 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..1f7f6af8a3 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.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) 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 testDB "" False MCError True 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 testDB "" False MCError True 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 testDB "" False noDownMigrations MCError True 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 079c583a6e..042f699d3f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE CPP #-} + import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -8,38 +10,56 @@ import Data.Time.Clock.System import JSONTests 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 LogError - 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 + 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