diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 52a0c343ff..46ee753438 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -444,17 +444,17 @@ func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTa throw r.unexpected } -func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) async throws -> [MsgContentTag] { +func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] { let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope)) if case let .chatContentTypes(types) = r { return types } throw r.unexpected } -func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) +func loadChat(chat: Chat, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, contentTag: contentTag, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { +func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { await MainActor.run { if clearItems { im.reversedChatItems = [] @@ -468,10 +468,11 @@ func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundIte openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : ( - search == "" + contentTag == nil && search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) ) ), + contentTag, search, openAroundItemId, { 0...0 } diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 93ecf870eb..9987fb4697 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -15,6 +15,7 @@ func apiLoadMessages( _ chatId: ChatId, _ im: ItemsModel, _ pagination: ChatPagination, + _ contentTag: MsgContentTag? = nil, _ search: String = "", _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } @@ -22,7 +23,7 @@ func apiLoadMessages( let chat: Chat let navInfo: NavigationInfo do { - (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: contentTag ?? im.contentTag, pagination: pagination, search: search) } catch let error { logger.error("apiLoadMessages error: \(responseError(error))") return diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 709758655f..dc1228fce8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -44,6 +44,8 @@ struct ChatView: View { @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed + @State private var contentFilter: ContentFilter? = nil + @State private var availableContent: [ContentFilter] = [.images, .files, .links] // opening GroupMemberInfoView on member icon @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @@ -528,16 +530,19 @@ struct ChatView: View { case let .direct(contact): HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } else { + contentFilterMenu(withLabel: false) } Menu { if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .audio) + } label: { + Label("Audio call", systemImage: "phone") + } + .disabled(!contact.ready || !contact.active) Button { CallController.shared.startCall(contact, .video) } label: { @@ -545,6 +550,9 @@ struct ChatView: View { } .disabled(!contact.ready || !contact.active) } + if let call = chatModel.activeCall, call.contact.id == cInfo.id { + contentFilterMenu(withLabel: true) + } searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) @@ -554,23 +562,24 @@ struct ChatView: View { } case let .group(groupInfo, _): HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - } - } + contentFilterMenu(withLabel: false) Menu { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } searchButton() ToggleNtfsButton(chat: chat) } label: { @@ -578,7 +587,10 @@ struct ChatView: View { } } case .local: - searchButton() + HStack { + contentFilterMenu(withLabel: false) + searchButton() + } default: EmptyView() } @@ -685,6 +697,7 @@ struct ChatView: View { } } } + updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, let draft = chatModel.draft { @@ -698,6 +711,22 @@ struct ChatView: View { floatingButtonModel.updateOnListChange(scrollView.listState) } + private func updateAvailableContent() { + Task { + let content: [ContentFilter] + do { + let contentTags = Set(try await apiGetChatContentTypes(chatId: chat.chatInfo.id)).union(ContentFilter.alwaysShow) + content = ContentFilter.allCases.filter { contentTags.contains($0.contentTag) } + } catch let error { + logger.error("apiGetChatContentTypes error: \(responseError(error))") + content = ContentFilter.allCases + } + await MainActor.run { + availableContent = content + } + } + } + private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -732,10 +761,14 @@ struct ChatView: View { } private func searchToolbar() -> some View { - HStack(spacing: 12) { + let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" + return HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") - TextField("Search", text: $searchText) + if let contentFilter { + Image(systemName: contentFilter.icon) + } + TextField(placeholder, text: $searchText) .focused($searchFocussed) .foregroundColor(theme.colors.onBackground) .frame(maxWidth: .infinity) @@ -1052,7 +1085,7 @@ struct ChatView: View { private func searchTextChanged(_ s: String) { Task { - await loadChat(chat: chat, im: im, search: s) + await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) mergedItems.boxedValue = MergedItems.create(im, revealedItems) await MainActor.run { scrollView.updateItems(mergedItems.boxedValue.items) @@ -1255,16 +1288,52 @@ struct ChatView: View { } } + private func contentFilterMenu(withLabel: Bool) -> some View { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + let icon = contentFilter == nil ? "photo.on.rectangle" : "photo.on.rectangle.fill" + if withLabel { + Label("Filter", systemImage: icon) + } else { + Image(systemName: icon) + } + } + } + private func focusSearch() { showSearch = true searchFocussed = true searchText = "" } + private func setContentFilter(_ type: ContentFilter) { + if (contentFilter == type) { return } + contentFilter = type + showSearch = true + searchText = "" + searchTextChanged("") + } + private func closeSearch() { showSearch = false searchText = "" searchFocussed = false + contentFilter = nil + updateAvailableContent() } private func closeKeyboardAndRun(_ action: @escaping () -> Void) { @@ -1285,7 +1354,7 @@ struct ChatView: View { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { - Image(systemName: "person.crop.circle.badge.plus") + Label("Invite member", systemImage: "person.crop.circle.badge.plus") } } @@ -1305,7 +1374,7 @@ struct ChatView: View { } } } label: { - Image(systemName: "link.badge.plus") + Label("Group link", systemImage: "link.badge.plus") } } @@ -1473,6 +1542,7 @@ struct ChatView: View { chat.chatInfo.id, im, pagination, + contentFilter?.contentTag, searchText, nil, { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } @@ -2957,6 +3027,66 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +enum ContentFilter: CaseIterable { + case images + case videos + case voice + case files + case links + + static let alwaysShow: Set = [.image, .link] + + var contentTag: MsgContentTag { + switch self { + case .images: .image + case .videos: .video + case .voice: .voice + case .files: .file + case .links: .link + } + } + + var label: LocalizedStringKey { + switch self { + case .images: "Images" + case .videos: "Videos" + case .voice: "Voice messages" + case .files: "Files" + case .links: "Links" + } + } + + var searchPlaceholder: LocalizedStringKey { + switch self { + case .images: "Search images" + case .videos: "Search videos" + case .voice: "Search voice messages" + case .files: "Search files" + case .links: "Search links" + } + } + + var icon: String { + switch self { + case .images: "photo" + case .videos: "video" + case .voice: "mic" + case .files: "doc" + case .links: "link" + } + } + + var iconFilled: String { + switch self { + case .images: "photo.fill" + case .videos: "video.fill" + case .voice: "mic.fill" + case .files: "doc.fill" + case .links: "link.circle.fill" + } + } +} + struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index f30061200c..5d39eb29f2 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin /local.properties /.idea !/.idea/codeStyles/* 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 b2817291ce..31edeec55a 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 @@ -3735,7 +3735,7 @@ sealed class CC { } "/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") } - is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)})" + is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)}" is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) 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 ed40150cb1..107d427556 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 @@ -27,11 +27,12 @@ suspend fun apiLoadMessages( chatType: ChatType, apiId: Long, pagination: ChatPagination, + contentTag: MsgContentTag? = null, search: String = "", openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.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 /** When [openAroundItemId] is provided, chatId can be different too */ if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) 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 26daee363f..7322e3b17d 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 @@ -45,6 +45,7 @@ import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.common.views.newchat.alertProfileImageSize import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.datetime.* @@ -144,6 +145,9 @@ fun ChatView( val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } + val contentFilter = rememberSaveable { mutableStateOf(null) } + val availableContent = remember { mutableStateOf>(ContentFilter.initialList) } + if (appPlatform.isAndroid) { DisposableEffect(Unit) { onDispose { @@ -170,7 +174,12 @@ fun ChatView( } showSearch.value = false searchText.value = "" + contentFilter.value = null + availableContent.value = ContentFilter.initialList selectedChatItems.value = null + if (chatsCtx.secondaryContextFilter == null) { + updateAvailableContent(chatRh, activeChat, availableContent) + } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) { withBGApi { val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) @@ -229,11 +238,11 @@ fun ChatView( val sameText = searchText.value == value // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it // (required on Android to have this check to prevent call to search with old text) - val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null && contentFilter.value == null val c = chatModel.getChat(chatInfo.id) - if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + if ((sameText && contentFilter.value == null) || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(chatsCtx, c, value) + apiFindMessages(chatsCtx, c, contentFilter.value?.contentTag, value) searchText.value = value } } @@ -486,7 +495,7 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes) + apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, contentFilter.value?.contentTag, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> @@ -742,14 +751,23 @@ fun ChatView( changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, closeSearch = { + onSearchValueChanged("") showSearch.value = false searchText.value = "" + contentFilter.value = null + // Update available content types when search closes + if (chatsCtx.secondaryContextFilter == null) { + updateAvailableContent(chatRh, activeChat, availableContent) + } }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), showSearch = showSearch, - showCommandsMenu = showCommandsMenu + showCommandsMenu = showCommandsMenu, + contentFilter = contentFilter, + availableContent = availableContent, + searchPlaceholder = contentFilter.value?.searchPlaceholder?.let { generalGetString(it) } ) } } @@ -785,6 +803,23 @@ fun ChatView( } } +fun updateAvailableContent(chatRh: Long?, activeChat: State, availableContent: MutableState>) { + withBGApi { + Log.e(TAG, "updateAvailableContent") + val chatInfo = activeChat.value?.chatInfo + if (chatInfo == null) return@withBGApi + val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) + if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi + if (types == null) { + availableContent.value = ContentFilter.entries + } else { + val typeSet: Set = types.union(ContentFilter.alwaysShow) + Log.e(TAG, "updateAvailableContent $typeSet") + availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) } + } + } +} + private fun connectingText(chatInfo: ChatInfo): String? { return when (chatInfo) { is ChatInfo.Direct -> @@ -879,7 +914,10 @@ fun ChatLayout( developerTools: Boolean, showViaProxy: Boolean, showSearch: MutableState, - showCommandsMenu: MutableState + showCommandsMenu: MutableState, + contentFilter: MutableState, + availableContent: State>, + searchPlaceholder: String? ) { val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } } val scope = rememberCoroutineScope() @@ -1063,7 +1101,7 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch, contentFilter, availableContent, searchPlaceholder) } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) @@ -1096,10 +1134,14 @@ fun BoxScope.ChatInfoToolbar( openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, - showSearch: MutableState + showSearch: MutableState, + contentFilter: MutableState, + availableContent: State>, + searchPlaceholder: String? ) { val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } + val showContentFilterMenu = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -1107,6 +1149,7 @@ fun BoxScope.ChatInfoToolbar( } else { onSearchValueChanged("") showSearch.value = false + contentFilter.value = null } } if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) { @@ -1115,104 +1158,141 @@ fun BoxScope.ChatInfoToolbar( val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() val activeCall by remember { chatModel.activeCall } - if (chatInfo is ChatInfo.Local) { - barButtons.add { - IconButton( - { - showMenu.value = false - showSearch.value = true - }, enabled = chatInfo.noteFolder.ready - ) { - Icon( - painterResource(MR.images.ic_search), - stringResource(MR.strings.search_verb).capitalize(Locale.current), - tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - } - } else { - menuItems.add { - ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { - showMenu.value = false - showSearch.value = true - }) - } - } - if (chatInfo is ChatInfo.Direct && chatInfo.contact.mergedPreferences.calls.enabled.forUser) { - if (activeCall == null) { - barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, enabled = chatInfo.contact.ready && chatInfo.contact.active - ) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), - tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - } - } else if (activeCall?.contact?.id == chatInfo.id && appPlatform.isDesktop) { - barButtons.add { - val call = remember { chatModel.activeCall }.value - val connectedAt = call?.connectedAt - if (connectedAt != null) { - val time = remember { mutableStateOf(durationText(0)) } - LaunchedEffect(Unit) { - while (true) { - time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) - delay(250) - } - } - val sp50 = with(LocalDensity.current) { 50.sp.toDp() } - Text(time.value, Modifier.widthIn(min = sp50)) - } - } - barButtons.add { - IconButton({ - showMenu.value = false - endCall() - }) { - Icon( - painterResource(MR.images.ic_call_end_filled), - null, - tint = MaterialTheme.colors.error - ) - } - } - } - if (chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { + val showContentFilterButton = availableContent.value.isNotEmpty() + val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id + + // Content filter button - shown in bar, or moved to menu during active call + if (showContentFilterButton) { + val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready + if (activeCallInChat) { menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { - showMenu.value = false - startCall(CallMediaType.Video) - }) - } - } - } else if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canAddMembers) { - if (!chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) - } + ItemAction( + stringResource(MR.strings.content_filter_menu_item), + painterResource(MR.images.ic_photo_library), + onClick = { + showMenu.value = false + showContentFilterMenu.value = true + } + ) } } else { barButtons.add { - IconButton({ - showMenu.value = false - openGroupLink(chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( + painterResource(MR.images.ic_photo_library), + null, + tint = MaterialTheme.colors.primary + ) } } } } + // Chat-type specific buttons + when (chatInfo) { + is ChatInfo.Local -> { + barButtons.add { + IconButton( + { + showMenu.value = false + showSearch.value = true + }, enabled = chatInfo.noteFolder.ready + ) { + Icon( + painterResource(MR.images.ic_search), + stringResource(MR.strings.search_verb).capitalize(Locale.current), + tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + } + is ChatInfo.Direct -> { + if (activeCall?.contact?.id == chatInfo.id) { + if (appPlatform.isDesktop) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } + } + } + // Call buttons moved to menu + if (chatInfo.contact.mergedPreferences.calls.enabled.forUser && chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { + menuItems.add { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showMenu.value = false + startCall(CallMediaType.Audio) + }) + } + menuItems.add { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showMenu.value = false + startCall(CallMediaType.Video) + }) + } + } + menuItems.add { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { + showMenu.value = false + showSearch.value = true + }) + } + } + is ChatInfo.Group -> { + // Add members / group link moved to menu + if (chatInfo.groupInfo.canAddMembers) { + if (!chatInfo.incognito) { + menuItems.add { + ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = { + showMenu.value = false + addMembers(chatInfo.groupInfo) + }) + } + } else { + menuItems.add { + ItemAction(stringResource(MR.strings.group_link), painterResource(MR.images.ic_add_link), onClick = { + showMenu.value = false + openGroupLink(chatInfo.groupInfo) + }) + } + } + } + menuItems.add { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { + showMenu.value = false + showSearch.value = true + }) + } + } + else -> {} + } + val enableNtfs = chatInfo.chatSettings?.enableNtfs if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { val ntfMode = remember { mutableStateOf(enableNtfs) } @@ -1242,13 +1322,27 @@ fun BoxScope.ChatInfoToolbar( } val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val searchTrailingContent: @Composable (() -> Unit)? = if (showContentFilterButton) {{ + IconButton({ showContentFilterMenu.value = true }) { + Icon( + painterResource(if (contentFilter.value == null) MR.images.ic_photo_library else MR.images.ic_photo_library_filled), + null, + Modifier.padding(4.dp), + tint = MaterialTheme.colors.primary + ) + } + }} else null + DefaultAppBar( navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } }, title = { ChatInfoToolbarTitle(chatInfo) }, onTitleClick = if (chatInfo is ChatInfo.Local) null else info, showSearch = showSearch.value, + searchAlwaysVisible = contentFilter.value != null, onTop = !oneHandUI.value || !chatBottomBar.value, + searchPlaceholder = searchPlaceholder, onSearchValueChanged = onSearchValueChanged, + searchTrailingContent = searchTrailingContent, buttons = { barButtons.forEach { it() } } ) Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) { @@ -1269,6 +1363,65 @@ fun BoxScope.ChatInfoToolbar( menuItems.forEach { it() } } } + val contentFilterWidth = remember { mutableStateOf(250.dp) } + val contentFilterHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showContentFilterMenu, + modifier = Modifier.onSizeChanged { with(density) { + contentFilterWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) contentFilterHeight.value = it.height.toDp() + } }, + offset = DpOffset(-contentFilterWidth.value, if (oneHandUI.value && chatBottomBar.value) -contentFilterHeight.value else AppBarHeight) + ) { + val contentFilterMenuItems: List<@Composable () -> Unit> = buildList { + availableContent.value.forEach { filter -> + val isSelected = contentFilter.value == filter + add { + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showContentFilterMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showContentFilterMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (oneHandUI.value && chatBottomBar.value) { + contentFilterMenuItems.asReversed().forEach { it() } + } else { + contentFilterMenuItems.forEach { it() } + } + } } } @@ -3425,7 +3578,10 @@ fun PreviewChatLayout() { developerTools = false, showViaProxy = false, showSearch = remember { mutableStateOf(false) }, - showCommandsMenu = remember { mutableStateOf(false) } + showCommandsMenu = remember { mutableStateOf(false) }, + contentFilter = remember { mutableStateOf(null) }, + availableContent = remember { mutableStateOf(ContentFilter.initialList) }, + searchPlaceholder = null ) } } @@ -3505,7 +3661,30 @@ fun PreviewGroupChatLayout() { developerTools = false, showViaProxy = false, showSearch = remember { mutableStateOf(false) }, - showCommandsMenu = remember { mutableStateOf(false) } + showCommandsMenu = remember { mutableStateOf(false) }, + contentFilter = remember { mutableStateOf(null) }, + availableContent = remember { mutableStateOf(ContentFilter.initialList) }, + searchPlaceholder = null ) } } + +enum class ContentFilter( + val contentTag: MsgContentTag, + val label: StringResource, + val searchPlaceholder: StringResource, + val icon: ImageResource, + val iconFilled: ImageResource +) { + Images(MsgContentTag.Image, MR.strings.content_filter_images, MR.strings.placeholder_search_images, MR.images.ic_image, MR.images.ic_image_filled), + Videos(MsgContentTag.Video, MR.strings.content_filter_videos, MR.strings.placeholder_search_videos, MR.images.ic_videocam, MR.images.ic_videocam_filled), + Voice(MsgContentTag.Voice, MR.strings.content_filter_voice_messages, MR.strings.placeholder_search_voice_messages, MR.images.ic_mic, MR.images.ic_mic_filled), + Files(MsgContentTag.File, MR.strings.content_filter_files, MR.strings.placeholder_search_files, MR.images.ic_draft, MR.images.ic_draft_filled), + Links(MsgContentTag.Link, MR.strings.content_filter_links, MR.strings.placeholder_search_links, MR.images.ic_link, MR.images.ic_link); + + companion object { + val alwaysShow: Set = setOf(MsgContentTag.Image, MsgContentTag.Link) + + val initialList: List = listOf(ContentFilter.Images, ContentFilter.Files, ContentFilter.Links) + } +} 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 77ab62dbf1..014a180712 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 @@ -228,6 +228,7 @@ suspend fun openChat( } else { ChatPagination.Initial(ChatPagination.INITIAL_COUNT) }, + contentTag = null, "", openAroundItemId ) @@ -241,11 +242,12 @@ suspend fun openLoadedChat(chat: Chat) { } } -suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) { +suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentTag: MsgContentTag?, search: String) { withContext(Dispatchers.Main) { chatsCtx.chatItems.clearAndNotify() } - apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) + val pagination = if (search.isNotEmpty() || contentTag != null) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination, contentTag, search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { 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 1c5f86c8b5..81fac40a40 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 @@ -29,7 +29,9 @@ fun DefaultAppBar( onTop: Boolean, showSearch: Boolean = false, searchAlwaysVisible: Boolean = false, + searchPlaceholder: String? = null, onSearchValueChanged: (String) -> Unit = {}, + searchTrailingContent: @Composable (() -> Unit)? = null, buttons: @Composable RowScope.() -> Unit = {}, ) { // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier @@ -78,7 +80,8 @@ fun DefaultAppBar( AppBar( title = { if (showSearch) { - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) + val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb) + SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) } else if (title != null) { title() } else if (titleText.value.isNotEmpty() && connection != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index 7124f34ac0..a122ddd885 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -10,6 +10,7 @@ import androidx.compose.material.TextFieldDefaults.indicatorLine import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -112,18 +113,26 @@ fun SearchTextField( placeholder = { Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis) }, - trailingIcon = if (searchText.value.text.isNotEmpty()) {{ - IconButton({ - if (alwaysVisible) { - keyboard?.hide() - focusManager.clearFocus() + trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = 8.dp) + ) { + if (searchText.value.text.isNotEmpty()) { + IconButton({ + if (alwaysVisible) { + keyboard?.hide() + focusManager.clearFocus() + } + searchText.value = TextFieldValue("") + onValueChange("") + }) { + Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary) + } } - searchText.value = TextFieldValue(""); - onValueChange("") - }, Modifier.offset(x = reducedCloseButtonPadding)) { - Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,) + trailingContent?.invoke() } - }} else trailingContent, + }} else null, singleLine = true, enabled = enabled, interactionSource = interactionSource, 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 371f0e076f..4d71073dac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -368,6 +368,18 @@ Edit Info Search + Search images + Search videos + Search voice messages + Search files + Search links + Images + Videos + Voice messages + Files + Links + All messages + Filter Archive Archive report Archive reports diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg new file mode 100644 index 0000000000..045484d0a1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg new file mode 100644 index 0000000000..091b0d4692 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg new file mode 100644 index 0000000000..72692b2e17 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 398a4afbff..02ec262986 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -768,6 +768,7 @@ Group: - itemTimed: [CITimed](#citimed)? - itemLive: bool? - userMention: bool +- hasLink: bool - deletable: bool - editable: bool - forwardedByMember: int64? diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index a05d549d17..cce79229d0 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -778,6 +778,7 @@ export interface CIMeta { itemTimed?: CITimed itemLive?: boolean userMention: boolean + hasLink: boolean deletable: boolean editable: boolean forwardedByMember?: number // int64 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f9871c87fb..c3f08ed010 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -127,7 +127,8 @@ library Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices - Simplex.Chat.Store.Postgres.Migrations.M20260109_chat_relays + Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link + Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays else exposed-modules: Simplex.Chat.Archive @@ -277,7 +278,8 @@ library Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices - Simplex.Chat.Store.SQLite.Migrations.M20260109_chat_relays + Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link + Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b7a6d8d2d3..0b1cc979c0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2265,7 +2265,8 @@ processChatCommand vr nm = \case (errs, ctSndMsgs :: [(Contact, SndMessage)]) <- partitionEithers . L.toList . zipWith3' combineResults ctConns sndMsgs <$> deliverMessagesB msgReqs_ timestamp <- liftIO getCurrentTime - lift . void $ withStoreBatch' $ \db -> map (createCI db user timestamp) ctSndMsgs + let hasLink = msgContentHasLink mc $ parseMaybeMarkdownList $ msgContentText mc + lift . void $ withStoreBatch' $ \db -> map (createCI db user hasLink timestamp) ctSndMsgs pure CRBroadcastSent {user, msgContent = mc, successes = length ctSndMsgs, failures = length errs, timestamp} where addContactConn :: Contact -> [(Contact, Connection)] -> [(Contact, Connection)] @@ -2280,9 +2281,9 @@ processChatCommand vr nm = \case combineResults (ct, _) (Right msg') (Right _) = Right (ct, msg') combineResults _ (Left e) _ = Left e combineResults _ _ (Left e) = Left e - createCI :: DB.Connection -> User -> UTCTime -> (Contact, SndMessage) -> IO () - createCI db user createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt + createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO () + createCI db user hasLink createdAt (ct, sndMsg) = + void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 04f13b3a53..2a3d71ea73 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2265,9 +2265,10 @@ saveSndChatItems user cd itemsData itemTimed live = do where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt + let hasLink_ = ciContentHasLink content (snd itemTexts) + ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt + let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci @@ -2300,9 +2301,10 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared if (ciRequiresAttention content || contactChatDeleted cd) then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt + let hasLink_ = ciContentHasLink content ft_ + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt + let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember createdAt ci' <- case cd of CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -2318,15 +2320,26 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = - let ts = ciContentTexts content - in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs + let ts@(_, ft_) = ciContentTexts content + hasLink_ = ciContentHasLink content ft_ + in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs = let itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention currentTs itemTs forwardedByMember showGroupAsSender currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender currentTs currentTs in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} +ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool +ciContentHasLink content ft_ = case ciMsgContent content of + Just mc -> msgContentHasLink mc ft_ + Nothing -> False + +msgContentHasLink :: MsgContent -> Maybe MarkdownList -> Bool +msgContentHasLink mc ft_ = case msgContentTag mc of + MCLink_ -> True + _ -> maybe False hasLinks ft_ + createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction @@ -2587,7 +2600,8 @@ createChatItems user itemTs_ dirsCIContents = do createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents where createACI (content, sharedMsgId) = do - ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId itemTs createdAt + let hasLink_ = ciContentHasLink content Nothing + ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci @@ -2617,10 +2631,11 @@ createLocalChatItems user cd itemsData createdAt = do pure items where createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded, ts) = do - ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt + createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do + let hasLink_ = ciContentHasLink content ft_ + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt + pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index fb44b416aa..3287b263d4 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -178,6 +178,16 @@ isSimplexLink = \case SimplexLink {} -> True _ -> False +isLink :: Format -> Bool +isLink = \case + Uri -> True + HyperLink {} -> True + SimplexLink {} -> True + _ -> False + +hasLinks :: MarkdownList -> Bool +hasLinks = any $ \(FormattedText f _) -> maybe False isLink f + markdownP :: Parser Markdown markdownP = mconcat <$> A.many' fragmentP where diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index f5d07d18af..056b857f80 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -499,6 +499,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, userMention :: Bool, -- True for messages that mention user or reply to user messages + hasLink :: BoolDef, deletable :: Bool, editable :: Bool, forwardedByMember :: Maybe GroupMemberId, @@ -510,11 +511,12 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta type ShowGroupAsSender = Bool -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, deletable, editable, forwardedByMember, showGroupAsSender, createdAt, updatedAt} + hasLink = BoolDef hasLink_ + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -540,6 +542,7 @@ dummyMeta itemId ts itemText = itemTimed = Nothing, itemLive = Nothing, userMention = False, + hasLink = BoolDef False, deletable = False, editable = False, forwardedByMember = Nothing, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 28ecbc829a..7093c89eec 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -525,9 +525,9 @@ setSupportChatMemberAttention db vr user g m memberAttention = do m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = - createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live hasLink createdAt = + createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -541,9 +541,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -558,15 +558,15 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ -> (Just $ Just userMemberId == memberId, memberId) -createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False itemTs Nothing +createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs = + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt = do DB.execute db [sql| @@ -575,20 +575,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, show_group_as_sender, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, 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 :. groupScopeRow :. 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 MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live), BI userMention, BI showGroupAsSender) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI showGroupAsSender) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of @@ -1045,7 +1045,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1078,7 +1078,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing False createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1470,6 +1470,12 @@ getChatItemIDs db User {userId} cInfo contentFilter range count search = case cI (grCond <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ") (userId, groupId) "item_ts" + (Nothing, Just MCLink_) -> + liftIO $ + idsQuery + (grCond <> " AND has_link = 1 ") + (userId, groupId) + "item_ts" (Nothing, Just mcTag) -> liftIO $ idsQuery @@ -1488,11 +1494,13 @@ getChatItemIDs db User {userId} cInfo contentFilter range count search = case cI grCond = " user_id = ? AND group_id = ? " DirectChat Contact {contactId} -> liftIO $ case contentFilter of Nothing -> idsQuery ctCond (userId, contactId) "created_at" + Just MCLink_ -> idsQuery (ctCond <> " AND has_link = 1 ") (userId, contactId) "created_at" Just mcTag -> idsQuery (ctCond <> " AND msg_content_tag = ? ") (userId, contactId, mcTag) "created_at" where ctCond = " user_id = ? AND contact_id = ? " LocalChat NoteFolder {noteFolderId} -> liftIO $ case contentFilter of Nothing -> idsQuery nfCond (userId, noteFolderId) "created_at" + Just MCLink_ -> idsQuery (nfCond <> " AND has_link = 1 ") (userId, noteFolderId) "created_at" Just mcTag -> idsQuery (nfCond <> " AND msg_content_tag = ? ") (userId, noteFolderId, mcTag) "created_at" where nfCond = " user_id = ? AND note_folder_id = ? " @@ -2191,7 +2199,7 @@ 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 BoolInt, BoolInt) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -2215,7 +2223,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -2248,7 +2256,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing False createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2286,7 +2294,7 @@ toGroupChatItem ( ( (itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow - :. (timedTTL, timedDeleteAt, itemLive, BI userMention) + :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_) ) :. (forwardedByMember, BI showGroupAsSender) @@ -2331,7 +2339,7 @@ toGroupChatItem _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2604,7 +2612,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -2959,7 +2967,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember, showGroupAsSender @@ -3068,7 +3076,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 0b4f76933c..9262713b4c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -25,7 +25,8 @@ import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector import Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables import Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices -import Simplex.Chat.Store.Postgres.Migrations.M20260109_chat_relays +import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link +import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -51,7 +52,8 @@ schemaMigrations = ("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations), ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), - ("20260109_chat_relays", m20260109_chat_relays, Just down_m20260109_chat_relays) + ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), + ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260122_has_link.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260122_has_link.hs new file mode 100644 index 0000000000..3ee456e958 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260122_has_link.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260122_has_link :: Text +m20260122_has_link = + [r| +ALTER TABLE chat_items ADD COLUMN has_link SMALLINT NOT NULL DEFAULT 0; + +UPDATE chat_items SET msg_content_tag = 'text' WHERE msg_content_tag = 'liveText'; + +UPDATE chat_items SET has_link = 1 +WHERE msg_content_tag = 'link' OR item_text LIKE '%https://%'; + +CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items(user_id, group_id, has_link, item_ts); +CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items(user_id, contact_id, has_link, created_at); +CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items(user_id, note_folder_id, has_link, created_at); +|] + +down_m20260122_has_link :: Text +down_m20260122_has_link = + [r| +DROP INDEX idx_chat_items_note_folder_has_link_created_at; +DROP INDEX idx_chat_items_contacts_has_link_created_at; +DROP INDEX idx_chat_items_groups_has_link_item_ts; + +ALTER TABLE chat_items DROP COLUMN has_link; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs similarity index 94% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs index 4059008b02..f86ee049e4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs @@ -1,13 +1,13 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260109_chat_relays where +module Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays where import Data.Text (Text) import qualified Data.Text as T import Text.RawString.QQ (r) -m20260109_chat_relays :: Text -m20260109_chat_relays = +m20260222_chat_relays :: Text +m20260222_chat_relays = T.pack [r| CREATE TABLE chat_relays( @@ -59,8 +59,8 @@ CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); ALTER TABLE group_members ADD COLUMN relay_link BYTEA; |] -down_m20260109_chat_relays :: Text -down_m20260109_chat_relays = +down_m20260222_chat_relays :: Text +down_m20260222_chat_relays = T.pack [r| ALTER TABLE users DROP COLUMN is_user_chat_relay; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index f41f193c4d..bcdcd5c992 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -342,7 +342,8 @@ CREATE TABLE test_chat_schema.chat_items ( user_mention smallint DEFAULT 0 NOT NULL, group_scope_tag text, group_scope_group_member_id bigint, - show_group_as_sender smallint DEFAULT 0 NOT NULL + show_group_as_sender smallint DEFAULT 0 NOT NULL, + has_link smallint DEFAULT 0 NOT NULL ); @@ -1886,6 +1887,10 @@ CREATE INDEX idx_chat_items_contacts_created_at ON test_chat_schema.chat_items U +CREATE INDEX idx_chat_items_contacts_has_link_created_at ON test_chat_schema.chat_items USING btree (user_id, contact_id, has_link, created_at); + + + CREATE INDEX idx_chat_items_contacts_msg_content_tag_created_at ON test_chat_schema.chat_items USING btree (user_id, contact_id, msg_content_tag, created_at); @@ -1946,6 +1951,10 @@ CREATE INDEX idx_chat_items_groups ON test_chat_schema.chat_items USING btree (u +CREATE INDEX idx_chat_items_groups_has_link_item_ts ON test_chat_schema.chat_items USING btree (user_id, group_id, has_link, item_ts); + + + CREATE INDEX idx_chat_items_groups_history ON test_chat_schema.chat_items USING btree (user_id, group_id, include_in_history, item_deleted, item_ts, chat_item_id); @@ -1974,6 +1983,10 @@ CREATE INDEX idx_chat_items_item_status ON test_chat_schema.chat_items USING btr +CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON test_chat_schema.chat_items USING btree (user_id, note_folder_id, has_link, created_at); + + + CREATE INDEX idx_chat_items_note_folder_msg_content_tag_created_at ON test_chat_schema.chat_items USING btree (user_id, note_folder_id, msg_content_tag, created_at); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 89d6193c1b..13691a64bd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -148,7 +148,8 @@ import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables import Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices -import Simplex.Chat.Store.SQLite.Migrations.M20260109_chat_relays +import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link +import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -297,7 +298,8 @@ schemaMigrations = ("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations), ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), - ("20260109_chat_relays", m20260109_chat_relays, Just down_m20260109_chat_relays) + ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), + ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260122_has_link.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260122_has_link.hs new file mode 100644 index 0000000000..132dc26ccf --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260122_has_link.hs @@ -0,0 +1,33 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260122_has_link :: Query +m20260122_has_link = + [sql| +UPDATE chat_items SET msg_content_tag = 'text' WHERE msg_content_tag = 'liveText'; + +UPDATE chat_items SET msg_content_tag = CAST(msg_content_tag as TEXT) WHERE typeof(msg_content_tag) = 'blob'; + +ALTER TABLE chat_items ADD COLUMN has_link INTEGER NOT NULL DEFAULT 0; + +UPDATE chat_items SET has_link = 1 +WHERE msg_content_tag = 'link' OR item_text LIKE '%https://%'; + +CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items(user_id, group_id, has_link, item_ts); +CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items(user_id, contact_id, has_link, created_at); +CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items(user_id, note_folder_id, has_link, created_at); +|] + +down_m20260122_has_link :: Query +down_m20260122_has_link = + [sql| +DROP INDEX idx_chat_items_note_folder_has_link_created_at; +DROP INDEX idx_chat_items_contacts_has_link_created_at; +DROP INDEX idx_chat_items_groups_has_link_item_ts; + +ALTER TABLE chat_items DROP COLUMN has_link; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs similarity index 95% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs index 6ebfede31d..eb2fb9f258 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260109_chat_relays where +module Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) @@ -18,8 +18,8 @@ import Database.SQLite.Simple.QQ (sql) -- - group_members.relay_link - relay link, saved on member record for user joining group -- - groups.relay_own_status - indicates for a relay client that it is chat relay for the group (RelayStatus) -- - groups.relay_request_* - relay request "work item" fields -m20260109_chat_relays :: Query -m20260109_chat_relays = +m20260222_chat_relays :: Query +m20260222_chat_relays = [sql| CREATE TABLE chat_relays( chat_relay_id INTEGER PRIMARY KEY, @@ -72,8 +72,8 @@ CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); ALTER TABLE group_members ADD COLUMN relay_link BLOB; |] -down_m20260109_chat_relays :: Query -down_m20260109_chat_relays = +down_m20260222_chat_relays :: Query +down_m20260222_chat_relays = [sql| ALTER TABLE users DROP COLUMN is_user_chat_relay; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index f5eae94ac2..566fe89768 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -867,7 +867,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -878,7 +878,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1247,7 +1247,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -1264,7 +1264,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember, showGroupAsSender @@ -1317,7 +1317,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -1448,7 +1448,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -4405,7 +4405,7 @@ Query: Plan: SEARCH files USING INDEX idx_files_user_id (user_id=?) LIST SUBQUERY 1 -SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?) SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) @@ -4451,12 +4451,12 @@ Query: user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, show_group_as_sender, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -5437,7 +5437,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5446,7 +5446,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.contact_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5464,7 +5464,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.group_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5482,7 +5482,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.note_folder_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5798,6 +5798,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = Plan: SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND has_link = 1 ORDER BY created_at DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=? AND has_link=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND msg_content_tag = ? ORDER BY created_at DESC, chat_item_id DESC LIMIT ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_msg_content_tag_created_at (user_id=? AND contact_id=? AND msg_content_tag=?) @@ -5818,6 +5822,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND has_link = 1 ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=? AND has_link=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_item_ts (user_id=? AND group_id=? AND msg_content_tag=?) @@ -5826,6 +5834,10 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_i Plan: SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND has_link = 1 ORDER BY created_at DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=? AND has_link=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND msg_content_tag = ? ORDER BY created_at DESC, chat_item_id DESC LIMIT ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=? AND note_folder_id=? AND msg_content_tag=?) @@ -5878,7 +5890,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_ Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5902,7 +5914,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND item_content_tag != 'chatBanner' Plan: -SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5914,7 +5926,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5938,7 +5950,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_content_tag != 'chatBanner' Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5950,7 +5962,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND note_folder_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -6241,7 +6253,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_msg_content_tag_created_at (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_to_subscribe (user_id=?) @@ -6405,7 +6417,7 @@ Query: SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id Plan: SCAN CONSTANT ROW SCALAR SUBQUERY 1 -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) Query: SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?) Plan: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 82f7a3b38f..4a0348db4b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -452,7 +452,8 @@ CREATE TABLE chat_items( user_mention INTEGER NOT NULL DEFAULT 0, group_scope_tag TEXT, group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, - show_group_as_sender INTEGER NOT NULL DEFAULT 0 + show_group_as_sender INTEGER NOT NULL DEFAULT 0, + has_link INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -1234,6 +1235,24 @@ CREATE INDEX idx_chat_items_note_folder_msg_content_tag_created_at ON chat_items msg_content_tag, created_at ); +CREATE INDEX idx_chat_items_groups_has_link_item_ts ON chat_items( + user_id, + group_id, + has_link, + item_ts +); +CREATE INDEX idx_chat_items_contacts_has_link_created_at ON chat_items( + user_id, + contact_id, + has_link, + created_at +); +CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items( + user_id, + note_folder_id, + has_link, + created_at +); CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays( user_id, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c133f2f8c8..d018eed4ee 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -161,7 +161,7 @@ relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts -getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {dbOptions = (dbOptions testCoreOpts) {dbKey}, maintenance}} +getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} #endif termSettings :: VirtualTerminalSettings diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index d745461628..2490e97af6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -156,6 +156,8 @@ chatDirectTests = do describe "delivery receipts" $ do it "should send delivery receipts" testSendDeliveryReceipts it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts + describe "link content filter" $ do + it "filter chat by link content" testLinkContentFilter describe "negotiate connection peer chat protocol version range" $ do describe "peer version range correctly set for new connection via invitation" $ do testInvVRange supportedChatVRange supportedChatVRange @@ -3311,3 +3313,43 @@ contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "connection not verified, use /code command to see security code" cc <## "quantum resistant end-to-end encryption" cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") + +testLinkContentFilter :: HasCallStack => TestParams -> IO () +testLinkContentFilter = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/c" + simplexLink <- getInvitation alice + + let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + alice ##> ("/_send @2 json [" <> linkPreview <> "]") + alice <# "@bob https://simplex.chat" + bob <# "alice> https://simplex.chat" + + alice #> "@bob check out https://example.com" + bob <# "alice> check out https://example.com" + + bob #> "@alice visit http://test.org" + alice <# "bob> visit http://test.org" + + alice #> ("@bob " <> simplexLink) + bob <#. "alice> https://simplex.chat/invitation#" + + bob #> "@alice [click here](https://link.example.com)" + alice <# "bob> [click here](https://link.example.com)" + + alice #> "@bob visit example.com for info" + bob <# "alice> visit example.com for info" + + alice #> "@bob hello, no links here" + bob <# "alice> hello, no links here" + + alice ##> "/_get content types @2" + alice <## "Chat content types: link, text" + alice #$> ("/_get chat @2 content=link count=100", chat, [(1, "https://simplex.chat"), (1, "check out https://example.com"), (0, "visit http://test.org"), (1, simplexLink), (0, "[click here](https://link.example.com)"), (1, "visit example.com for info")]) + + bob ##> "/_get content types @2" + bob <## "Chat content types: link, text" + bob #$> ("/_get chat @2 content=link count=100", chat, [(0, "https://simplex.chat"), (0, "check out https://example.com"), (1, "visit http://test.org"), (0, simplexLink), (1, "[click here](https://link.example.com)"), (0, "visit example.com for info")]) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 530b85fa91..7baa12fdd4 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -100,6 +100,26 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX bob <## "Chat content types: file, text" bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) + -- Test file with link in text - should appear in both file and link filters + alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"check https://example.com for docs\"}}]" + alice <# "@bob check https://example.com for docs" + alice <# "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 2 to cancel sending" + bob <# "alice> check https://example.com for docs" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + alice <## "completed uploading file 2 (test.pdf) for bob" + + alice ##> "/_get content types @2" + alice <## "Chat content types: file, text" + alice #$> ("/_get chat @2 content=file count=100", chatF, [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg"), ((1, "check https://example.com for docs"), Just "./tests/fixtures/test.pdf")]) + alice #$> ("/_get chat @2 content=link count=100", chatF, [((1, "check https://example.com for docs"), Just "./tests/fixtures/test.pdf")]) + + bob ##> "/_get content types @2" + bob <## "Chat content types: file, text" + bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg"), ((0, "check https://example.com for docs"), Nothing)]) + bob #$> ("/_get chat @2 content=link count=100", chatF, [((0, "check https://example.com for docs"), Nothing)]) + testSendImage :: HasCallStack => TestParams -> IO () testSendImage = testChat2 aliceProfile bobProfile $ @@ -375,7 +395,7 @@ testGroupSendImage = bob ##> "/_get content types #1" bob <## "Chat content types: image, text" bob #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) - + cath #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg"), ((0, "received"), Nothing), ((1, "received too"), Nothing)]) cath ##> "/_get content types #1" cath <## "Chat content types: image, text" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index fd7d3ae8c7..ad0d0651b1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -133,6 +133,8 @@ chatGroupTests = do describe "group delivery receipts" $ do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts + describe "link content filter" $ do + it "filter group chat by link content" testGroupLinkContentFilter describe "direct connections in group are not established based on chat protocol version" $ do it "direct contacts are not created" testNoGroupDirectConns it "members have different local display names in different groups" testNoDirectDifferentLDNs @@ -8691,3 +8693,40 @@ testChannels2RelaysIncognito ps = dan ?#> "#team hey" [bob, cath] *<# ("#team " <> danIncognito <> "> hey") [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") + +testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () +testGroupLinkContentFilter = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + alice ##> ("/_send #1 json [" <> linkPreview <> "]") + alice <# "#team https://simplex.chat" + concurrently_ + (bob <# "#team alice> https://simplex.chat") + (cath <# "#team alice> https://simplex.chat") + + threadDelay 1000000 + + bob #> "#team check out https://example.com" + concurrently_ + (alice <# "#team bob> check out https://example.com") + (cath <# "#team bob> check out https://example.com") + + cath #> "#team hello, no links here" + concurrently_ + (alice <# "#team cath> hello, no links here") + (bob <# "#team cath> hello, no links here") + + alice ##> "/_get content types #1" + alice <## "Chat content types: link, text" + alice #$> ("/_get chat #1 content=link count=100", chat, [(1, "https://simplex.chat"), (0, "check out https://example.com")]) + + bob ##> "/_get content types #1" + bob <## "Chat content types: link, text" + bob #$> ("/_get chat #1 content=link count=100", chat, [(0, "https://simplex.chat"), (1, "check out https://example.com")]) + + cath ##> "/_get content types #1" + cath <## "Chat content types: link, text" + cath #$> ("/_get chat #1 content=link count=100", chat, [(0, "https://simplex.chat"), (0, "check out https://example.com")]) diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index e4a4da5166..6760901c1d 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -26,6 +26,8 @@ chatLocalChatsTests = do describe "batch create messages" $ do it "create multiple messages api" testCreateMulti it "create multiple messages with files" testCreateMultiFiles + describe "link content filter" $ do + it "filter notes by link content" testLinkContentFilter testNotes :: TestParams -> IO () testNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -231,3 +233,18 @@ testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_get chat *1 count=3" r <- chatF <$> getTermLine alice r `shouldBe` [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")] + +testLinkContentFilter :: TestParams -> IO () +testLinkContentFilter ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + createCCNoteFolder alice + + let linkPreview = "{\"msgContent\": {\"type\": \"link\", \"text\": \"https://simplex.chat\", \"preview\": {\"uri\": \"https://simplex.chat\", \"title\": \"SimpleX Chat\", \"description\": \"SimpleX Chat\", \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + alice ##> ("/_create *1 json [" <> linkPreview <> "]") + alice <# "* https://simplex.chat" + + alice >* "check out https://example.com" + alice >* "hello, no links here" + + alice ##> "/_get content types *1" + alice <## "Chat content types: link, text" + alice #$> ("/_get chat *1 content=link count=100", chat, [(1, "https://simplex.chat"), (1, "check out https://example.com")])