diff --git a/frontend/src/components/Chat/ChatBubble.spec.js b/frontend/src/components/Chat/ChatBubble.spec.js index 3f0adf4cff..aac7560e82 100644 --- a/frontend/src/components/Chat/ChatBubble.spec.js +++ b/frontend/src/components/Chat/ChatBubble.spec.js @@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { flushPromises, mount } from '@vue/test-utils' import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import { ref } from 'vue' import ChatBubble from './ChatBubble.vue' import { forgetAllChatImages, rememberChatImage } from '@/composables/useChatImages' import { CHAT_VIDEO_JOIN } from '@/utils/chatVideoApp' +import { CHAT_SEARCH } from '@/utils/chatSearch' import { withChatVideoTopic } from '@/utils/chatVideoTopic' import { LIST_AVATAR_SIZE } from '@/constants' @@ -175,6 +177,82 @@ describe('ChatBubble', () => { expect(link.attributes('href')).toBe(address) }) + /** + * E-057 (Bernd, 30.09.2026): the search in the thread marks its hits where they stand -- in the + * words, the bold runs, a link's text, the subject, a transfer's memo. The thread provides the + * needle (useChatThreadSearch); here a stand-in does. + */ + describe('the hits of the search in the thread', () => { + const mountSearched = (message, needle = 'bank') => { + wrapper = mount(ChatBubble, { + props: { message, alias: 'Lena' }, + global: { + provide: { [CHAT_SEARCH]: ref(needle) }, + stubs: { IMdiEmailOutline: true, IBiCopy: true, IMdiFileDocumentOutline: true }, + }, + }) + return wrapper + } + const marks = () => wrapper.findAll('mark.chat-search-mark').map((m) => m.text()) + + it('marks every place in the words, keeping the words as they are', () => { + mountSearched({ ...THEIRS, body: 'Die Bank am Waldrand, eine schöne bank.' }) + expect(marks()).toEqual(['Bank', 'bank']) + expect(wrapper.find('.chat-message-text').text()).toBe( + 'Die Bank am Waldrand, eine schöne bank.', + ) + }) + + it('marks in bold runs, in a link and in the subject', () => { + mountSearched({ + ...THEIRS, + subject: 'Die Bank', + body: 'Das ist **die Bank** unter https://bank.example.org/weg', + }) + expect(wrapper.find('[data-test="chat-bubble-subject"] mark').text()).toBe('Bank') + expect(wrapper.find('.chat-message-text strong mark').text()).toBe('Bank') + expect(wrapper.find('.chat-message-text a mark').text()).toBe('bank') + expect(wrapper.find('.chat-message-text a').attributes('href')).toBe( + 'https://bank.example.org/weg', + ) + }) + + it("marks a transfer's memo", () => { + mountSearched({ + ...THEIRS, + transfer: true, + subject: 'Lena hat Dir 10 gesendet', + body: 'Für die Bank', + }) + expect(wrapper.find('.memo-text mark').text()).toBe('Bank') + }) + + // Folded as the search compares: the mark stands on the letters as written. + it('marks without regard to case and accents', () => { + mountSearched({ ...THEIRS, body: 'Treffen im CAFÉ' }, 'cafe') + expect(marks()).toEqual(['CAFÉ']) + }) + + it('marks nothing while nothing is searched, nor outside a thread', () => { + mountSearched({ ...THEIRS, body: 'Die Bank' }, '') + expect(marks()).toEqual([]) + wrapper.unmount() + mountBubble({ ...THEIRS, body: 'Die Bank' }) + expect(marks()).toEqual([]) + }) + + it('rings the bubble the search stands on', () => { + wrapper = mount(ChatBubble, { + props: { message: { ...THEIRS, body: 'Die Bank' }, alias: 'Lena', searchCurrent: true }, + global: { provide: { [CHAT_SEARCH]: ref('bank') }, stubs: { IMdiEmailOutline: true } }, + }) + expect(bubble().classes()).toContain('is-search-current') + wrapper.unmount() + mountSearched({ ...THEIRS, body: 'Die Bank' }) + expect(bubble().classes()).not.toContain('is-search-current') + }) + }) + /** * Bernd, 29.09.2026: behind a video room's link a button copies it -- to hand the room on to * another chat, or to meet there again -- for everybody who reads the message, the room with its diff --git a/frontend/src/components/Chat/ChatBubble.vue b/frontend/src/components/Chat/ChatBubble.vue index 74eed9da00..48e7c24a49 100644 --- a/frontend/src/components/Chat/ChatBubble.vue +++ b/frontend/src/components/Chat/ChatBubble.vue @@ -4,7 +4,11 @@ class="chat-bubble-row" :class="[ message.mine ? 'chat-bubble-mine' : 'chat-bubble-theirs', - { 'chat-bubble-transfer': message.transfer, 'chat-bubble-in-group': face }, + { + 'chat-bubble-transfer': message.transfer, + 'chat-bubble-in-group': face, + 'is-search-current': searchCurrent, + }, ]" :style="face ? { '--chat-bubble-face': `${LIST_AVATAR_SIZE}px` } : undefined" data-test="chat-bubble" @@ -65,7 +69,7 @@ data-test="chat-bubble-subject" > - {{ message.subject }} + @@ -129,6 +133,7 @@ import AppAvatar from '@/components/AppAvatar.vue' import ChatBubbleImage from '@/components/Chat/ChatBubbleImage.vue' import ChatMessageText from '@/components/Chat/ChatMessageText' import ChatTransferCoin from '@/components/Chat/ChatTransferCoin.vue' +import { ChatSearchText } from '@/components/Chat/chatSearchMarks' import MemoText from '@/components/TransactionRows/MemoText' import Name from '@/components/TransactionRows/Name.vue' import { avatarZoomBindings } from '@/composables/useAvatarZoom' @@ -172,6 +177,8 @@ const props = defineProps({ * the same writer. The thread decides it; it knows the message before. */ showWriter: { type: Boolean, default: true }, + /** The hit the thread's search stands on (E-057): the bubble is ringed, its marks stronger. */ + searchCurrent: { type: Boolean, default: false }, }) /** @@ -525,3 +532,29 @@ const addToCalendar = () => { padding: 0 0.5rem 0.15rem; } + + diff --git a/frontend/src/components/Chat/ChatComposeBar.vue b/frontend/src/components/Chat/ChatComposeBar.vue index f7b7cf116f..a0070d1d5c 100644 --- a/frontend/src/components/Chat/ChatComposeBar.vue +++ b/frontend/src/components/Chat/ChatComposeBar.vue @@ -75,7 +75,10 @@ not one would send a screen reader into keys that do nothing. The button says whether the entries are shown (`aria-expanded`) and which they are (`aria-controls`); Tab walks them, Esc closes them. --> +
* on any link. On a phone, and where no window provides the question, it opens the room straight * away, as it always did. Whether this is a computer is asked at the click, nothing kept. * + * Searched for in the thread (E-057), the hits are marked where they stand -- in the words, the + * bold runs and the addresses as shown; a file card is a card, not words, and stays as it is. + * * A link to files on SwissTransfer, Dropbox, Google Drive, OneDrive or WeTransfer is shown as a * file card in place of the address (Paket D, E-044): Gradido stores no files, the message * carries the link, and the card names the service and where it leads. The words around it stay; @@ -79,8 +83,10 @@ export default { }, setup(props) { const join = inject(CHAT_VIDEO_JOIN, null) - return () => - h( + const search = useChatSearchNeedle() + return () => { + const needle = search?.value ?? '' + return h( 'span', { class: 'chat-message-text' }, aroundFileCards(chatTextParts(props.text)).flatMap((part) => { @@ -108,19 +114,20 @@ export default { } : {}), }, - shown, + chatSearchMarked(shown, needle), ) // Right behind the link, in the room it keeps at its end (ChatVideoLinkCopy). return video ? [link, h(ChatVideoLinkCopy, { href: part.value })] : link } if (part.type === 'email') { - return h('a', { href: `mailto:${part.value}` }, part.value) + return h('a', { href: `mailto:${part.value}` }, chatSearchMarked(part.value, needle)) } if (part.type === 'bold') { - return h('strong', part.value) + return h('strong', chatSearchMarked(part.value, needle)) } - return part.value + return chatSearchMarked(part.value, needle) }), ) + } }, } diff --git a/frontend/src/components/Chat/ChatSearchBar.spec.js b/frontend/src/components/Chat/ChatSearchBar.spec.js new file mode 100644 index 0000000000..8f07434105 --- /dev/null +++ b/frontend/src/components/Chat/ChatSearchBar.spec.js @@ -0,0 +1,126 @@ +// AI-GENERATED — not an architecture reference +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ChatSearchBar from './ChatSearchBar.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, values) => (values ? `${key} ${JSON.stringify(values)}` : key), + }), +})) + +const found = (extra = {}) => ({ + searching: true, + count: 12, + current: 3, + busy: false, + capped: false, + ...extra, +}) + +let wrapper +const mountBar = (result, modelValue = 'bank') => { + wrapper = mount(ChatSearchBar, { + props: { modelValue, ...(result ? { result } : {}) }, + attachTo: document.body, + global: { stubs: { IMdiChevronUp: true, IMdiChevronDown: true } }, + }) + return wrapper +} +const field = () => wrapper.find('[data-test="chat-search-field"]') +const count = () => wrapper.find('[data-test="chat-search-count"]').text() +const older = () => wrapper.find('[data-test="chat-search-older"]') +const newer = () => wrapper.find('[data-test="chat-search-newer"]') + +describe('ChatSearchBar', () => { + afterEach(() => { + wrapper?.unmount() + }) + + // The magnifier opened it: the keyboard goes straight into the field. + it('takes the keyboard into its field', () => { + mountBar(found()) + expect(document.activeElement).toBe(field().element) + expect(field().attributes('aria-label')).toBe('chatSearch.open') + expect(field().attributes('placeholder')).toBe('chatSearch.placeholder') + }) + + it('hands on what is typed', async () => { + mountBar(found(), '') + await field().setValue('Waldrand') + expect(wrapper.emitted('update:modelValue')).toEqual([['Waldrand']]) + }) + + it.each([ + ['nothing while fewer than two letters are typed', { searching: false }, ''], + ['"Suche …" while older pages come', { busy: true }, 'chatSearch.loading'], + ['that nothing was found', { count: 0, current: 0 }, 'chatSearch.none'], + ['which hit of how many', {}, 'chatSearch.count {"current":3,"count":12}'], + ])('says %s', (what, extra, words) => { + mountBar(found(extra)) + expect(count()).toBe(words) + }) + + it('says it where a screen reader hears it', () => { + mountBar(found()) + expect(wrapper.find('[data-test="chat-search-count"]').attributes('aria-live')).toBe('polite') + }) + + // ↑ to the older hit, ↓ to the newer -- named for the ear and the pointer. + it('steps to the older and the newer hit', async () => { + mountBar(found()) + expect(older().attributes('aria-label')).toBe('chatSearch.older') + expect(newer().attributes('aria-label')).toBe('chatSearch.newer') + await older().trigger('click') + await newer().trigger('click') + expect(wrapper.emitted('older')).toHaveLength(1) + expect(wrapper.emitted('newer')).toHaveLength(1) + }) + + it.each([ + ['at the oldest hit, not to an older one', found({ current: 1 }), 'older'], + ['at the newest hit, not to a newer one', found({ current: 12 }), 'newer'], + ['without a hit, to neither', found({ count: 0, current: 0 }), 'both'], + ['while older pages come, to neither', found({ busy: true }), 'both'], + ])('does not step %s', async (what, result, which) => { + mountBar(result) + const blocked = which === 'both' ? ['older', 'newer'] : [which] + for (const name of blocked) { + const button = name === 'older' ? older() : newer() + expect(button.attributes('aria-disabled')).toBe('true') + await button.trigger('click') + expect(wrapper.emitted(name)).toBeUndefined() + } + }) + + // As in a messenger: Enter to the older hit, Shift+Enter to the newer. + it('steps with Enter and Shift+Enter', async () => { + mountBar(found()) + await field().trigger('keydown', { key: 'Enter' }) + await field().trigger('keydown', { key: 'Enter', shiftKey: true }) + expect(wrapper.emitted('older')).toHaveLength(1) + expect(wrapper.emitted('newer')).toHaveLength(1) + }) + + // Esc closes the search -- and only the search: the window around it must not hear it. + it('closes on Esc, and the window around it does not hear the key', async () => { + mountBar(found()) + let heard = 0 + const listener = () => (heard += 1) + document.body.addEventListener('keydown', listener) + await field().trigger('keydown', { key: 'Escape' }) + document.body.removeEventListener('keydown', listener) + expect(wrapper.emitted('close')).toHaveLength(1) + expect(heard).toBe(0) + }) + + it('says where only the newest messages were searched', () => { + mountBar(found({ capped: true })) + expect(wrapper.find('[data-test="chat-search-capped"]').text()).toBe( + 'chatSearch.capped {"count":1000}', + ) + wrapper.unmount() + mountBar(found()) + expect(wrapper.find('[data-test="chat-search-capped"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/Chat/ChatSearchBar.vue b/frontend/src/components/Chat/ChatSearchBar.vue new file mode 100644 index 0000000000..ee32c2aa5f --- /dev/null +++ b/frontend/src/components/Chat/ChatSearchBar.vue @@ -0,0 +1,181 @@ + + + + + + diff --git a/frontend/src/components/Chat/ChatThread.spec.js b/frontend/src/components/Chat/ChatThread.spec.js index 388b9e2d33..795b97d276 100644 --- a/frontend/src/components/Chat/ChatThread.spec.js +++ b/frontend/src/components/Chat/ChatThread.spec.js @@ -43,6 +43,12 @@ const beatBrings = async (...chatMessages) => { // And their switch for the transfers in the conversations (Einstellungen › Nachrichten), which a // test sets and the afterEach puts back to "not known". const storeState = vi.hoisted(() => ({ gradidoID: 'me-id', username: 'Bernd' })) +// The search's reading limit (E-057), small enough for a test to reach: the thread's is a thousand. +vi.mock('@/utils/chatSearch', async (original) => ({ + ...(await original()), + CHAT_SEARCH_MAX_MESSAGES: 6, +})) + vi.mock('vuex', () => ({ useStore: () => ({ state: storeState }), })) @@ -702,6 +708,238 @@ describe('ChatThread', () => { }) }) + /** + * E-055 (Bernd, 30.09.2026): opened from a group, a first word is one tap on the arrow -- the + * window hands in "Hallo …", and the thread puts it into the field where the two have never + * written. In the window's first form only the text: no paperclip until there is a conversation. + */ + describe('the greeting from a group', () => { + const greeted = (extra = {}) => + mountThread(LENA, { + props: { member: LENA, alias: 'Lena', greeting: 'Hallo Lena', ...extra }, + }) + + it('puts it into the field where the two have never written', async () => { + greeted() + await arrive(page([])) + expect(field().element.value).toBe('Hallo Lena') + }) + + it('leaves the field empty where they have written', async () => { + greeted() + await arrive(page([1, 2])) + expect(field().element.value).toBe('') + }) + + // What a start held is the member's own words: they win. + it('gives way to the words a start held', async () => { + holdChatText({ gradidoID: 'lena-id', text: 'Hier ist die Datei:' }) + greeted() + await arrive(page([])) + expect(field().element.value).toBe('Hier ist die Datei:') + }) + + it('goes out as the first message with one press of the arrow', async () => { + serverSends.mockResolvedValue(ownCopy(99, 'Hallo Lena')) + greeted() + await arrive(page([])) + + await wrapper.find('[data-test="chat-compose-send"]').trigger('click') + await flushPromises() + + expect(serverSends).toHaveBeenCalledWith( + expect.objectContaining({ + ref: { gradidoID: 'lena-id', communityUuid: 'home-uuid' }, + body: 'Hallo Lena', + }), + ) + }) + + it('offers only the text in the first form, and the paperclip with the conversation', async () => { + greeted({ textOnly: true }) + await arrive(page([])) + expect(wrapper.find('[data-test="chat-compose-attach"]').exists()).toBe(false) + expect(field().exists()).toBe(true) + + await wrapper.setProps({ textOnly: false }) + expect(wrapper.find('[data-test="chat-compose-attach"]').exists()).toBe(true) + // The same bar: the words stay in their field. + expect(field().element.value).toBe('Hallo Lena') + }) + }) + + /** + * E-057 (Bernd, 30.09.2026): the window's search field hands its words in (`search`); the thread + * loads its older pages for them, marks the hits, stands on the newest, and says what it found. + */ + describe('the search', () => { + const withBodies = (entries, { hasMore = false } = {}) => ({ + hasMore, + mutedByMe: false, + messages: entries.map(([n, body]) => ({ ...message(n), body })), + }) + const searched = async (search, first, older = []) => { + mountThread(LENA, { props: { member: LENA, alias: 'Lena', search: '' } }) + server.olderPages.push(...older) + await arrive(first) + await wrapper.setProps({ search }) + for (let round = 0; round < 5; round += 1) await flushPromises() + } + const found = () => wrapper.emitted('search').at(-1)[0] + const bubbleOf = (key) => wrapper.find(`[data-test="chat-bubble"][data-key="${key}"]`) + const marks = () => wrapper.findAll('mark.chat-search-mark').map((m) => m.text()) + + it('marks the hits and stands on the newest', async () => { + await searched( + 'bank', + withBodies([ + [1, 'Die Bank'], + [2, 'Hallo'], + [3, 'Bank am Weg'], + ]), + ) + + expect(found()).toEqual({ searching: true, count: 2, current: 2, busy: false, capped: false }) + expect(marks()).toEqual(['Bank', 'Bank']) + expect(bubbleOf(3).classes()).toContain('is-search-current') + expect(bubbleOf(1).classes()).not.toContain('is-search-current') + }) + + it('loads the older pages for it, and searches them too', async () => { + await searched( + 'bank', + withBodies( + [ + [5, 'x'], + [6, 'Bank'], + ], + { hasMore: true }, + ), + [ + withBodies([ + [1, 'Bank alt'], + [2, 'y'], + ]), + ], + ) + + expect(server.fetchMore).toHaveBeenCalledTimes(1) + expect(found()).toMatchObject({ count: 2, current: 2, busy: false }) + expect(bubbleOf(1).find('mark').text()).toBe('Bank') + }) + + it("steps with the window's arrows", async () => { + await searched( + 'bank', + withBodies([ + [1, 'Bank'], + [2, 'x'], + [3, 'Bank'], + ]), + ) + wrapper.vm.searchStep(-1) + await flushPromises() + expect(found()).toMatchObject({ current: 1 }) + expect(bubbleOf(1).classes()).toContain('is-search-current') + wrapper.vm.searchStep(1) + await flushPromises() + expect(bubbleOf(3).classes()).toContain('is-search-current') + }) + + // The member pressed "load older" a moment before: the search waits for that page and goes + // on from there, asking for nothing twice. + it('waits for a page already on its way', async () => { + mountThread(LENA, { props: { member: LENA, alias: 'Lena', search: '' } }) + server.olderPages.push( + withBodies( + [ + [3, 'Bank mitte'], + [4, 'y'], + ], + { hasMore: true }, + ), + withBodies([ + [1, 'Bank alt'], + [2, 'x'], + ]), + ) + await arrive( + withBodies( + [ + [5, 'x'], + [6, 'Bank'], + ], + { hasMore: true }, + ), + ) + let release + server.gate = new Promise((resolve) => (release = resolve)) + await wrapper.find('[data-test="chat-thread-older"]').trigger('click') + await wrapper.setProps({ search: 'bank' }) + await flushPromises() + expect(server.fetchMore).toHaveBeenCalledTimes(1) + + server.gate = null + release() + for (let round = 0; round < 5; round += 1) await flushPromises() + expect(server.fetchMore).toHaveBeenCalledTimes(2) + expect(found()).toMatchObject({ count: 3, current: 3, busy: false }) + }) + + // coderabbit (#4025): the limit counts the transfers with the messages -- a pair with few + // messages and many transfers is bounded too. + it('counts the transfers towards the limit', async () => { + bookingsAsked.mockImplementation(async () => + bookingsPage( + [41, 42, 43, 44].map((n) => booking(n, { at: `2026-09-22T09:0${n - 40}:00.000Z` })), + 100, + ), + ) + await searched( + 'bank', + withBodies( + [ + [5, 'Bank'], + [6, 'x'], + ], + { hasMore: true }, + ), + [ + withBodies([ + [1, 'Bank alt'], + [2, 'y'], + ]), + ], + ) + expect(server.fetchMore).not.toHaveBeenCalled() + expect(found()).toMatchObject({ count: 1, capped: true, busy: false }) + }) + + it("searches the transfers' memos too", async () => { + bookingsAsked.mockImplementation(async () => + bookingsPage([booking(40, { at: '2026-09-22T10:30:00.000Z' })]), + ) + await searched( + 'memo 40', + withBodies([ + [1, 'Hallo'], + [2, 'Tschüss'], + ]), + ) + expect(found()).toMatchObject({ count: 1, current: 1 }) + expect(bubbleOf('transfer-40').find('.memo-text mark').text()).toBe('memo 40') + }) + + it('lets the marks go when the search closes', async () => { + await searched('bank', withBodies([[1, 'Die Bank']])) + await wrapper.setProps({ search: '' }) + await flushPromises() + expect(marks()).toEqual([]) + expect(bubbleOf(1).classes()).not.toContain('is-search-current') + expect(found()).toMatchObject({ searching: false, count: 0 }) + }) + }) + describe('the question it asks', () => { // KF-004: the person is named by the pair; the page is the server's own default of 50. it('asks for the thread by the pair, fifty at a time', () => { diff --git a/frontend/src/components/Chat/ChatThread.vue b/frontend/src/components/Chat/ChatThread.vue index 3c2fd0f1ba..8b57d4b321 100644 --- a/frontend/src/components/Chat/ChatThread.vue +++ b/frontend/src/components/Chat/ChatThread.vue @@ -80,10 +80,12 @@ @@ -113,7 +115,8 @@ :sending="sending" :failed="sendFailed" :failed-reason="sendRefusal" - :initial-text="heldText" + :initial-text="openingText" + :text-only="textOnly" @send="send" /> @@ -128,7 +131,7 @@