Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions frontend/src/components/Chat/ChatBubble.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/components/Chat/ChatBubble.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -65,7 +69,7 @@
data-test="chat-bubble-subject"
>
<chat-transfer-coin v-if="message.transfer" />
<span>{{ message.subject }}</span>
<chat-search-text :text="message.subject" />
</div>
<!-- A transfer's memo as the booking list shows it (MemoText): its addresses as links, its
stars as stars -- it is the booking's text, not a chat message. -->
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 },
})

/**
Expand Down Expand Up @@ -525,3 +532,29 @@ const addToCalendar = () => {
padding: 0 0.5rem 0.15rem;
}
</style>

<style lang="scss">
/* Block comments only: lightningcss parses SFC style blocks, and a double slash is not a comment
to it.

The hits of the thread's search (E-057), marked where they stand -- in the words, the subject,
a transfer's memo, a link's text. Not scoped: the marks are made by render functions
(chatSearchMarks) inside this bubble, which carry no scope of this file. The same warm yellow in
both themes, with a dark text on it, so a hit reads alike on the grey, the gold and the dark
bubble; the hit the search stands on in a stronger orange, and its bubble ringed. */
.chat-bubble-row .chat-search-mark {
padding: 0 0.05em;
border-radius: 0.15em;
background: #ffe58f;
color: #1d1d1b;
}

.chat-bubble-row.is-search-current .chat-search-mark {
background: #ffb340;
}

.chat-bubble-row.is-search-current .chat-bubble {
outline: 2px solid #ffb340;
outline-offset: 2px;
}
</style>
8 changes: 7 additions & 1 deletion frontend/src/components/Chat/ChatComposeBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<!-- Not in the contact window's first form (E-055): a first word to somebody met in a group
is only the text, and the paperclip comes with the conversation. -->
<div
v-if="!textOnly"
ref="attachArea"
class="chat-compose-attach-area"
@keydown.esc="closeMenuByKey"
Expand Down Expand Up @@ -362,7 +365,8 @@ const props = defineProps({
failedReason: { type: String, default: '' },
/**
* The words to begin with: what stood in the field, not sent yet, when iOS started the wallet
* over (utils/chatReturn). Read once, when the bar is made.
* over (utils/chatReturn), or "Hallo …" for a first word from a group (E-055). Read once, when
* the bar is made.
*/
initialText: { type: String, default: '' },
/** A group's bar (P5): `name` is the group's, and the box is the announcement. */
Expand All @@ -371,6 +375,8 @@ const props = defineProps({
canAnnounce: { type: Boolean, default: false },
/** In a group: how many would get the announcement at most -- everybody but the sender. */
announceTo: { type: Number, default: 0 },
/** Only the text, no paperclip: the contact window's first form (E-055). */
textOnly: { type: Boolean, default: false },
})

const emit = defineEmits(['send'])
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/components/Chat/ChatMessageText.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { h, inject } from 'vue'
import ChatFileCard from '@/components/Chat/ChatFileCard.vue'
import ChatVideoLinkCopy from '@/components/Chat/ChatVideoLinkCopy.vue'
import { chatSearchMarked, useChatSearchNeedle } from '@/components/Chat/chatSearchMarks'
import { fileLinkService } from '@/utils/chatFileLink'
import { chatTextParts } from '@/utils/chatTextParts'
import { CHAT_VIDEO_JOIN, chatVideoAppUrl, offersJitsiApp } from '@/utils/chatVideoApp'
Expand Down Expand Up @@ -67,6 +68,9 @@ const aroundFileCards = (parts) =>
* 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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
}),
)
}
},
}
126 changes: 126 additions & 0 deletions frontend/src/components/Chat/ChatSearchBar.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading