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 @@
+
+
+
+
+
+
+
+ {{ countWords }}
+
+
+
+
+
+
+
+ {{ t('chatSearch.capped', { count: CHAT_SEARCH_MAX_MESSAGES }) }}
+
+
+
+
+
+
+
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 @@