diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index 0054f1f0..236c0b7c 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -50,6 +50,7 @@ let _replySendFailureState = null; let _newMessageSendFailureState = null; let _replySendPending = false; let _newMessageSendPending = false; +let _replyContextPrefix = null; let _replyDeliveryAttempt = null; let _newMessageDeliveryAttempt = null; let _deliveryKeySequence = 0; @@ -67,6 +68,7 @@ function exchangeScope(row = _exchange) { } export function resetMessagePanelContext() { + clearReplyContext(); const input = _messagesContainer?.querySelector('#messageInput'); if (_exchange && input) _exchangeDrafts.set(exchangeScope(), input.value); _exchangeGeneration += 1; @@ -723,6 +725,7 @@ function renderThreadList() { * Select a conversation to view. */ async function selectConversation(userId) { + clearReplyContext(); const renderedSelection = _messagesContainer?.querySelector( '.message-thread-item.selected', ); @@ -1098,6 +1101,55 @@ async function loadMessageRecommendationReview(messageId, { forceReload = false } } +// Keep the quote in the ordinary draft/body so existing persistence and retry +// retain it, including when the original message is later unavailable. +function clearReplyContext({ removeQuote = false } = {}) { + const input = _messagesContainer?.querySelector('#messageInput'); + if (removeQuote && input && _replyContextPrefix) { + if (!input.value.startsWith(_replyContextPrefix)) { + showToast('The quote was edited. Remove it directly from your draft.', 'info'); + return false; + } + input.value = input.value.slice(_replyContextPrefix.length); + resizeCompactDraft(input); + input.dispatchEvent(new Event('input', { bubbles: true })); + } + _replyContextPrefix = null; + _messagesContainer?.querySelector('.message-reply-context-controls')?.remove(); + return true; +} + +function selectReplyContext(messageId) { + if (_replySendPending || _exchange?.other_participant_ids?.length > 1) return; + const input = _messagesContainer?.querySelector('#messageInput'); + const message = _currentMessages.find(item => item.concept_id === messageId); + if (!input || !message) { + showToast('This message is no longer available. Your draft has been kept.', 'warning'); + return; + } + if (!clearReplyContext({ removeQuote: true })) return; + const sender = message.relationships?.['#V#has_sender']?.[0] || ''; + const content = message.concept_data?.content_fallback || '[Message text unavailable]'; + _replyContextPrefix = `Replying to ${formatUserName(sender)} (${messageId}):\n${content.split(/\r?\n/u).map(line => `> ${line}`).join('\n')}\n\n`; + input.value = _replyContextPrefix + input.value; + const controls = document.createElement('div'); + controls.className = 'message-reply-context-controls'; + const note = document.createElement('span'); + note.textContent = 'Quoted message included in your reply. '; + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.textContent = 'Cancel reply context'; + cancel.onclick = () => { + if (!_replySendPending) clearReplyContext({ removeQuote: true }); + }; + controls.append(note, cancel); + input.closest('#messageComposeArea').prepend(controls); + resizeCompactDraft(input); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); +} + /** * Render messages in the conversation view. */ @@ -1160,7 +1212,7 @@ async function renderMessages() { - ${msg.concept_id ? `` : ''} @@ -1209,6 +1261,10 @@ async function renderMessages() { status.textContent = copied ? 'Copied' : 'Copy failed'; }; }); + contentEl.querySelectorAll('.message-reply-context-btn').forEach(button => { + button.disabled = Boolean(_exchange?.other_participant_ids?.length > 1); + button.onclick = () => selectReplyContext(button.dataset.messageId); + }); contentEl.querySelectorAll('.message-discuss-btn').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); @@ -2272,6 +2328,7 @@ async function handleSendReply() { _currentConversationUserId === submittedConversationUserId && activeReplyInput?.value.trim() === content ) { + clearReplyContext(); activeReplyInput.value = ''; resizeCompactDraft(activeReplyInput); } diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 3f65846f..9f6ba9b2 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -2047,6 +2047,7 @@ body.von-signed-out { .task-discuss-btn, .message-discuss-btn, +.message-reply-context-btn, .concept-discuss-button { border: 1px solid #86efac; border-radius: 5px; @@ -2061,7 +2062,8 @@ body.von-signed-out { font-size: 0.75rem; } -.message-discuss-btn { +.message-discuss-btn, +.message-reply-context-btn { margin-left: 7px; padding: 1px 5px; font-size: 0.68rem; @@ -2157,6 +2159,7 @@ body.von-signed-out { .task-discuss-btn:hover, .message-discuss-btn:hover, +.message-reply-context-btn:hover, .concept-discuss-button:hover { background: #dcfce7; border-color: #4ade80; diff --git a/tests/browser/messageReplyContext.cjs b/tests/browser/messageReplyContext.cjs new file mode 100644 index 00000000..e10cb93a --- /dev/null +++ b/tests/browser/messageReplyContext.cjs @@ -0,0 +1,73 @@ +// Fixture-backed browser check of the production Messages module and CSS. +// No live accounts, backend writes or model calls. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); + +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const markup = '
'; +const evidence = process.argv[2]; +if (evidence) fs.mkdirSync(evidence, { recursive: true }); +const server = http.createServer((req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(` + ${markup}`); + return; + } + const file = path.resolve(root, `.${req.url}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) { + res.writeHead(404).end(); + return; + } + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); +}); + +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + for (const width of [1280, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + let sent; + let messages = [{ concept_id: '#V#source', concept_data: { content_fallback: 'Please review the experiment results.' }, relationships: { '#V#has_sender': ['#V#bob'] } }]; + await page.route('**/api/**', async route => { + const url = route.request().url(); + let data = {}; + if (url.endsWith('/messages/') && route.request().method() === 'POST') { + sent = route.request().postDataJSON(); + messages = [{ concept_id: '#V#reply', concept_data: { content_fallback: sent.content }, relationships: { '#V#has_sender': ['#V#alice'] } }]; + data = { success: true }; + } else if (url.includes('/messages/exchange')) data = { messages, current_user_id: '#V#alice' }; + else if (url.includes('/messages/threads')) data = { threads: [] }; + await route.fulfill({ json: data }); + }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + const panel = await import('/static/js/components/messagePanel.js'); + await panel.openMessageExchange({ session_id: 'existing', viewer_id: '#V#alice', session_name: 'Bob', + participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], organisation_concept_id: '#V#lab' }); + }); + const input = page.locator('#messageInput'); + await input.fill('I will review them today.'); + await page.getByRole('button', { name: 'Reply with context', exact: true }).click(); + await expect(input).toHaveValue(/Please review the experiment results/); + await expect(page.getByText('Quoted message included in your reply.')).toBeVisible(); + await page.getByRole('button', { name: 'Cancel reply context' }).click(); + await expect(input).toHaveValue('I will review them today.'); + await page.getByRole('button', { name: 'Reply with context', exact: true }).click(); + if (evidence) await page.screenshot({ path: path.join(evidence, `reply-context-${width}.png`) }); + await page.locator('#sendMessageBtn').click(); + await expect(page.locator('.message-content')).toContainText('I will review them today.'); + await expect(page.locator('.message-content')).toContainText('Please review the experiment results.'); + assert.deepEqual(sent.recipient_ids, ['#V#bob']); + assert.equal(sent.organisation_concept_id, '#V#lab'); + assert.match(sent.content, /#V#source/); + await page.close(); + } + console.log('PASS: Messages selection, cancellation, current exchange send and visible saved quote at 1280 and 390px.'); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/frontend/messagePanelReplyContext.test.js b/tests/frontend/messagePanelReplyContext.test.js new file mode 100644 index 00000000..e3fb8385 --- /dev/null +++ b/tests/frontend/messagePanelReplyContext.test.js @@ -0,0 +1,182 @@ +/** @jest-environment jsdom */ + +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ + getJson: jest.fn(), + postJson: jest.fn(), + postJsonDetailed: jest.fn(), +})); + +jest.mock('../../src/frontend/web/von_interface/static/js/utils/toast.js', () => ({ + showToast: jest.fn(), +})); + +const modulePath = '../../src/frontend/web/von_interface/static/js/components/messagePanel.js'; + +function renderFixture() { + document.body.innerHTML = ` + + + `; +} + +function flushUi() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function setMessageSessionScope({ actorId, organisationId }) { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: actorId })); + sessionStorage.setItem('von_current_org', JSON.stringify({ concept_id: organisationId })); +} + +function mockReplyReads(getJson, { + actorId = '#V#user_alice', + recipientIds = ['#V#user_bob'], + threadIdByRecipient = {}, +} = {}) { + getJson.mockImplementation(async (url) => { + if (url === '/api/messages/threads?limit=20') { + return { + threads: recipientIds.map((recipientId) => ({ + _id: [recipientId], + last_message: { + concept_data: { content_fallback: 'Earlier message' }, + }, + message_count: 1, + })), + }; + } + if (url === '/api/messages/unread/count') { + return { unread_count: 0 }; + } + const conversationMatch = url.match( + /^\/api\/messages\/conversation\/(.+)\?limit=50$/u, + ); + if (conversationMatch) { + const recipientId = decodeURIComponent(conversationMatch[1]); + const threadId = threadIdByRecipient[recipientId] || ''; + return { + messages: [{ + concept_id: `#V#message_for_${recipientId.replace(/^#V#/u, '')}`, + relationships: { + '#V#has_sender': [actorId], + '#V#has_recipient': [recipientId], + ...(threadId ? { '#V#is_part_of_thread': [threadId] } : {}), + }, + concept_data: { + content_fallback: 'Earlier message', + read_by: [], + }, + created_at: '2026-08-28T00:00:00Z', + }], + current_user_id: actorId, + }; + } + throw new Error(`Unexpected getJson call: ${url}`); + }); +} + +describe('reply with selected message context', () => { + let api; + let panel; + const input = () => document.querySelector('#messageInput'); + const select = () => document.querySelector('.message-reply-context-btn').click(); + beforeEach(async () => { + localStorage.clear(); sessionStorage.clear(); + renderFixture(); window.scrollTo = jest.fn(); + api = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + panel = require(modulePath); + setMessageSessionScope({ actorId: '#V#user_alice', organisationId: '#V#lab' }); + mockReplyReads(api.getJson, { threadIdByRecipient: { '#V#user_bob': '#V#thread_1' } }); + api.postJson.mockResolvedValue({}); + api.postJsonDetailed.mockResolvedValue({ data: { success: true }, status: 201 }); + global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({}), text: async () => '{}' })); + panel.initializeMessagePanel(); + await panel.showMessagesTab(); await flushUi(); + }); + afterEach(() => { jest.resetModules(); jest.clearAllMocks(); delete global.fetch; }); + + test('selects visibly in the current composer and cancels without losing the draft', () => { + input().value = 'My answer'; + const discuss = jest.fn(); document.addEventListener('von:discussConcept', discuss); + select(); + expect(input().value).toContain('> Earlier message'); + expect(input().value).toContain('#V#message_for_user_bob'); + expect(document.activeElement).toBe(input()); + expect(document.querySelector('.message-thread-item.selected').dataset.userId).toBe('#V#user_bob'); + expect(discuss).not.toHaveBeenCalled(); + document.querySelector('.message-reply-context-controls button').click(); + expect(input().value).toBe('My answer'); + document.removeEventListener('von:discussConcept', discuss); + }); + + test('reselecting replaces the quote and edited quotes are never silently removed', () => { + input().value = 'My answer'; select(); select(); + expect(input().value.match(/Replying to/g)).toHaveLength(1); + input().value = input().value.replace('Earlier', 'Edited'); + const edited = input().value; + document.querySelector('.message-reply-context-controls button').click(); + expect(input().value).toBe(edited); + expect(require('../../src/frontend/web/von_interface/static/js/utils/toast.js').showToast) + .toHaveBeenCalledWith(expect.stringContaining('edited'), 'info'); + }); + + test('sends and renders the quote through the existing thread and keeps it on failure', async () => { + input().value = 'My answer'; select(); + const draft = input().value; + api.postJsonDetailed.mockRejectedValueOnce(new Error('Offline')); + document.querySelector('#sendMessageBtn').click(); await flushUi(); + expect(input().value).toBe(draft); + api.getJson.mockImplementation(async url => { + if (url.includes('/conversation/')) return { current_user_id: '#V#user_alice', messages: [{ + concept_id: '#V#sent', concept_data: { content_fallback: draft }, + relationships: { '#V#has_sender': ['#V#user_alice'] } + }] }; + return { threads: [], unread_count: 0 }; + }); + document.querySelector('#sendMessageBtn').click(); await flushUi(); + expect(api.postJsonDetailed).toHaveBeenLastCalledWith('/api/messages/', expect.objectContaining({ + content: draft, recipient_ids: ['#V#user_bob'], organisation_concept_id: '#V#lab' + })); + expect(document.querySelector('.message-content').textContent).toContain('Earlier message'); + expect(document.querySelector('.message-content').textContent).toContain('My answer'); + expect(input().value).toBe(''); + }); + + test('changes source, retains a deleted source snapshot, and stays in the selected exchange', async () => { + let messages = [ + { concept_id: '#V#first', concept_data: { content_fallback: 'First source' } }, + { concept_id: '#V#second', concept_data: {} } + ]; + api.postJson.mockImplementation(async () => ({ messages, current_user_id: '#V#user_alice' })); + const row = { session_id: 'existing-exchange', viewer_id: '#V#user_alice', + session_name: 'Bob', participant_ids: ['#V#user_alice', '#V#user_bob'], + other_participant_ids: ['#V#user_bob'], organisation_concept_id: '#V#original_lab' }; + await panel.openMessageExchange(row); + input().value = 'Answer'; select(); + document.querySelectorAll('.message-reply-context-btn')[1].click(); + expect(input().value).not.toContain('First source'); + expect(input().value).toContain('#V#second'); + expect(input().value).toContain('[Message text unavailable]'); + const draft = input().value; + messages = []; + document.body.insertAdjacentHTML('beforeend', ''); + await panel.refreshOpenMessageExchange(); + expect(input().value).toBe(draft); + document.querySelector('#sendMessageBtn').click(); await flushUi(); + expect(api.postJsonDetailed).toHaveBeenLastCalledWith('/api/messages/', expect.objectContaining({ + content: draft, recipient_ids: ['#V#user_bob'], organisation_concept_id: '#V#original_lab' + })); + expect(api.postJson).toHaveBeenLastCalledWith('/api/messages/exchange', expect.objectContaining({ + participant_ids: row.participant_ids, organisation_concept_id: '#V#original_lab' + })); + }); + + test('unavailable selection keeps the draft', () => { + input().value = 'Keep this'; + document.querySelector('.message-reply-context-btn').dataset.messageId = '#V#deleted'; + select(); + expect(input().value).toBe('Keep this'); + expect(require('../../src/frontend/web/von_interface/static/js/utils/toast.js').showToast) + .toHaveBeenCalledWith(expect.stringContaining('no longer available'), 'warning'); + }); +});