From dbd8f30bfbdfb161b68f0ccc7fd60f59fa4951c5 Mon Sep 17 00:00:00 2001 From: Bernd Hueckstaedt <1311557+BerndHueckstaedt@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:44:52 +0200 Subject: [PATCH 1/9] feat(frontend): a first word to a group member in one tap Bernd, 30.09.2026 (E-055): contact from a group should be quick -- say during a video call running in the Jitsi app in front. Tapping another member's name in a group (its member list, or over their message) now opens their contact window over the group's, and "Hallo " already stands in the field wherever the two have never written: a first word is one tap on the arrow. - Somebody who is no contact yet gets the window's first form instead of the send form: face, name, community and address, the thread, and only the text field -- no Gradido button, no camera, bell or heart, no paperclip. The first message goes by mail as every first one does (E-024) and makes them a contact, in this community or another; the window then becomes the whole one without closing, the page puts the server's contact row in and asks its list again. Closing leads back into the group. - A contact without a conversation yet, opened from a group: the whole window as always, with the greeting in the field (Bernd's choice). Opened from anywhere else, nothing changes. - useContactWindow: `open(contact, { fromGroup, known })` sets `greet` and `firstContact` for the opening; `fillIn` swaps in the server's contact row. - ChatThread hands the greeting to an empty field unless a start held words for it; ChatComposeBar takes `textOnly`. - chatThread.firstContactGreeting in ten languages, worded like the mails' own greeting (core helloName). Co-Authored-By: Claude Opus 5.5 --- .../src/components/Chat/ChatComposeBar.vue | 8 +- .../src/components/Chat/ChatThread.spec.js | 60 +++++++++++ frontend/src/components/Chat/ChatThread.vue | 19 +++- .../components/Contacts/ContactWindow.spec.js | 92 +++++++++++++++- .../src/components/Contacts/ContactWindow.vue | 44 +++++++- frontend/src/composables/useContactWindow.js | 46 +++++++- frontend/src/locales/de.json | 1 + frontend/src/locales/el.json | 1 + frontend/src/locales/en.json | 1 + frontend/src/locales/es.json | 1 + frontend/src/locales/fr.json | 1 + frontend/src/locales/it.json | 1 + frontend/src/locales/nl.json | 1 + frontend/src/locales/pt.json | 1 + frontend/src/locales/ru.json | 1 + frontend/src/locales/tr.json | 1 + frontend/src/pages/Contacts.spec.js | 102 +++++++++++++++--- frontend/src/pages/Contacts.vue | 47 +++++--- 18 files changed, 392 insertions(+), 36 deletions(-) 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. --> +
{ }) }) + /** + * 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') + }) + }) + 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..241205f72d 100644 --- a/frontend/src/components/Chat/ChatThread.vue +++ b/frontend/src/components/Chat/ChatThread.vue @@ -113,7 +113,8 @@ :sending="sending" :failed="sendFailed" :failed-reason="sendRefusal" - :initial-text="heldText" + :initial-text="openingText" + :text-only="textOnly" @send="send" /> @@ -180,6 +181,13 @@ const props = defineProps({ * Without it the key is made from `member` as it stands. */ memberKey: { type: String, default: '' }, + /** + * "Hallo …" for the field where the two have no conversation yet (E-055): the contact window + * hands it in when it was opened from a group. Empty otherwise. + */ + greeting: { type: String, default: '' }, + /** Only the text, no paperclip: the contact window's first form (E-055). */ + textOnly: { type: Boolean, default: false }, }) /** @@ -568,6 +576,15 @@ watch( { immediate: true }, ) +/** + * The words the bar begins with: what a start held for this conversation (above), or -- where + * the two have never written -- the greeting the window handed in (E-055). The bar reads them + * once, when it is made; a thread with messages begins with an empty field. + */ +const openingText = computed( + () => heldText.value || (state.value === 'empty' ? props.greeting : ''), +) + /** * The read pointer on opening, to the highest id the first page brought (E-017: the marker is * the row number). Not for an empty thread -- there is nothing to have read -- not for an older diff --git a/frontend/src/components/Contacts/ContactWindow.spec.js b/frontend/src/components/Contacts/ContactWindow.spec.js index 647600b6c3..8a67e97400 100644 --- a/frontend/src/components/Contacts/ContactWindow.spec.js +++ b/frontend/src/components/Contacts/ContactWindow.spec.js @@ -119,9 +119,9 @@ let threadsMade = [] describe('ContactWindow', () => { let wrapper - const mountWindow = (contact = CONTACT) => { + const mountWindow = (contact = CONTACT, extra = {}) => { wrapper = mount(ContactWindow, { - props: { modelValue: true, contact }, + props: { modelValue: true, contact, ...extra }, global: { mocks: { $t: (key, values) => (values ? `${key} ${JSON.stringify(values)}` : key), @@ -159,7 +159,13 @@ describe('ContactWindow', () => { // does (ChatMessageText), so that a test can click an invitation's link through it. ChatThread: { name: 'ChatThread', - props: { member: Object, alias: String, memberKey: String }, + props: { + member: Object, + alias: String, + memberKey: String, + greeting: String, + textOnly: Boolean, + }, emits: ['chatConversation'], inject: { join: { from: CHAT_VIDEO_JOIN, default: null } }, mounted() { @@ -171,7 +177,7 @@ describe('ContactWindow', () => { }, }, template: - '
', + '
', }, AppAvatar: { props: ['initials'], @@ -208,6 +214,84 @@ describe('ContactWindow', () => { const bell = () => wrapper.find('[data-test="contact-window-bell"]') const sendButton = () => wrapper.find('[data-test="contact-window-send"]') + /** + * E-055 (Bernd, 30.09.2026): opened from a group, a first word is one tap on the arrow -- + * "Hallo …" in the field where the two have never written. For somebody who is no contact yet + * the window's first form: only the text, until the first message makes them a contact. + */ + describe("the window's first form, and the greeting", () => { + // A member of a group as the page hands them over: the pair and the name, no figures. + const MEMBER = { user: { ...CONTACT.user }, homeCommunity: true } + const thread = () => wrapper.find('[data-test="chat-thread"]') + const marks = () => + ['contact-window-video', 'contact-window-bell', 'heart'].filter((hook) => + wrapper.find(`[data-test="${hook}"]`).exists(), + ) + + it('offers only the text to somebody who is no contact yet, "Hallo …" in the field', async () => { + mountWindow(MEMBER, { greet: true, firstContact: true }) + + expect(wrapper.find('[data-test="contact-window-name"]').text()).toBe('Carla-Sonne') + expect(wrapper.find('[data-test="contact-window-community"]').text()).toBe('Gradido-Akademie') + expect(wrapper.find('[data-test="contact-window-meta"]').text()).toBe('') + expect(sendButton().exists()).toBe(false) + expect(marks()).toEqual([]) + expect(thread().attributes('data-text-only')).toBe('true') + expect(thread().attributes('data-greeting')).toBe( + 'chatThread.firstContactGreeting {"name":"Carla-Sonne"}', + ) + + // The thread found no conversation: still only the text, and nothing to say. + await threadSays({ exists: false, mutedByMe: false }) + expect(sendButton().exists()).toBe(false) + expect(marks()).toEqual([]) + expect(wrapper.emitted('contactMade')).toBeUndefined() + }) + + it('becomes the whole window with the first message, and says so once', async () => { + mountWindow(MEMBER, { greet: true, firstContact: true }) + await threadSays({ exists: false, mutedByMe: false }) + + await threadSays({ exists: true, mutedByMe: false }) + expect(sendButton().exists()).toBe(true) + expect(marks()).toEqual(['contact-window-video', 'contact-window-bell', 'heart']) + expect(thread().attributes('data-text-only')).toBe('false') + expect(wrapper.emitted('contactMade')).toHaveLength(1) + + await threadSays({ exists: true, mutedByMe: true }) + expect(wrapper.emitted('contactMade')).toHaveLength(1) + }) + + // The page's list was behind: the two have written already. The whole window at once, and the + // page is told, so that it puts the server's row in. + it('is the whole window at once where a conversation is there already', async () => { + mountWindow(MEMBER, { greet: true, firstContact: true }) + await threadSays({ exists: true, mutedByMe: false }) + + expect(sendButton().exists()).toBe(true) + expect(wrapper.emitted('contactMade')).toHaveLength(1) + }) + + it('greets from a group a contact too, in the whole window', async () => { + mountWindow(CONTACT, { greet: true }) + + expect(sendButton().exists()).toBe(true) + expect(thread().attributes('data-text-only')).toBe('false') + expect(thread().attributes('data-greeting')).toBe( + 'chatThread.firstContactGreeting {"name":"Carla-Sonne"}', + ) + await threadSays({ exists: true, mutedByMe: false }) + expect(wrapper.emitted('contactMade')).toBeUndefined() + }) + + it('greets nobody opened from anywhere else', () => { + mountWindow() + + expect(thread().attributes('data-greeting')).toBe('') + expect(thread().attributes('data-text-only')).toBe('false') + }) + }) + it('names the person, their community and their face', () => { mountWindow() diff --git a/frontend/src/components/Contacts/ContactWindow.vue b/frontend/src/components/Contacts/ContactWindow.vue index 15e477b8f9..054d360f01 100644 --- a/frontend/src/components/Contacts/ContactWindow.vue +++ b/frontend/src/components/Contacts/ContactWindow.vue @@ -148,7 +148,14 @@ `is-tight`: where a language's word makes the button so wide that the marks no longer fit beside it, the row is set closer, with a smaller font -- there only (Bernd, 26.09.2026). See `fitSendRow`. --> -
+ +