From a29c32908eab08dc7c3f585a7aaaa4de11396863 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 12:39:13 -0400 Subject: [PATCH] Add mobile message conversation actions and private concept references --- ..._conversations_and_participant_profiles.md | 16 ++++ src/backend/server/routes/message_routes.py | 27 +++++++ .../services/message_catalogue_service.py | 68 ++++++++++++++++ .../web/von_interface/static/js/chatTab.js | 2 +- .../js/components/conversationCatalogue.js | 80 +++++++++++++++---- .../web/von_interface/static/styles.css | 15 ++++ .../test_message_exchange_catalogue.py | 75 +++++++++++++++++ tests/backend/test_message_routes.py | 49 ++++++++++++ tests/browser/messageConversationMenu.cjs | 65 +++++++++++++++ .../frontend/messageConversationMenu.test.js | 39 +++++++++ 10 files changed, 419 insertions(+), 17 deletions(-) create mode 100644 tests/browser/messageConversationMenu.cjs create mode 100644 tests/frontend/messageConversationMenu.test.js diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md index 09d9d3908..3be94e693 100644 --- a/docs/engineering/unified_conversations_and_participant_profiles.md +++ b/docs/engineering/unified_conversations_and_participant_profiles.md @@ -134,3 +134,19 @@ precision, draft and selection preservation, exact group/organisation identity, profile permission denial, publication read-back, and authenticated desktop and narrow-viewport checks. The native task holds dated delivery evidence and the current merge decision. Repository publication does not activate a running server. + +## Message conversation references + +Message rows expose a touch-accessible actions button and the shared, viewport- +bounded conversation menu. Actions include opening the exchange, copying its +exact structured reference, pinning/hiding (or their inverses), and participant +profiles. Chat-history-only actions such as moving a transcript are not offered +for a message exchange. + +**Copy Concept ID** calls `POST /api/messages/exchange/reference`. After checking +that the authenticated actor can read an existing exact participant/organisation +exchange, the service lazily creates and reads back an actor-private conversation +reference concept. Its `attributes.message_exchange_reference` and description +retain the source locator; messages remain canonical message concepts. References +are stable per actor/exchange, not shared ownership or an access grant. Repeated +copies recheck source access. No existing message migration is required. diff --git a/src/backend/server/routes/message_routes.py b/src/backend/server/routes/message_routes.py index bb1cf9b10..baf758ea5 100644 --- a/src/backend/server/routes/message_routes.py +++ b/src/backend/server/routes/message_routes.py @@ -1070,3 +1070,30 @@ def message_exchange_preference(): return jsonify(success=True, preference=result) except PermissionError: return jsonify(error="conversation_unavailable"), 403 + + +@message_bp.route("/exchange/reference", methods=["POST"]) +def message_exchange_reference(): + from ...services.message_catalogue_service import materialise_exchange_reference + + actor = _get_current_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + payload = request.get_json(silent=True) or {} + try: + org = ( + _normalise_concept_id(payload["organisation_concept_id"]) + if "organisation_concept_id" in payload + else _get_current_org_concept_id(actor, require_known_window=True) + ) + result = materialise_exchange_reference( + actor, payload.get("participant_ids"), organisation=org + ) + return jsonify(success=True, **result) + except PermissionError: + return jsonify(error="conversation_unavailable"), 403 + except (ValueError, TypeError): + return jsonify(error="invalid_conversation"), 400 + except Exception: + _log.exception("Message exchange reference unavailable") + return jsonify(error="conversation_reference_unavailable"), 503 diff --git a/src/backend/services/message_catalogue_service.py b/src/backend/services/message_catalogue_service.py index 9defec1c9..e2d3196a1 100644 --- a/src/backend/services/message_catalogue_service.py +++ b/src/backend/services/message_catalogue_service.py @@ -318,3 +318,71 @@ def get_exchange(actor, participants, *, organisation=None, limit=50, before=Non "before": cursor, "current_user_id": actor, } + + +def materialise_exchange_reference(actor, participants, *, organisation=None): + """Create an actor-private reference, never a second message transcript. + + Access is rechecked on every request, including reuse. The actor is part of + the reference identity so another participant cannot inherit its visibility. + """ + from .concept_service import ( + create_concept, + get_concept_by_concept_id_exact, + ConceptNotFoundError, + ) + + def read_reference(concept_id): + try: + return get_concept_by_concept_id_exact(concept_id) + except ConceptNotFoundError: + return None + + from ..security.visibility_predicates import get_specific_to_user_values + + exchange = get_exchange(actor, participants, organisation=organisation, limit=1) + if not exchange["messages"]: + raise PermissionError("Conversation unavailable.") + with override_current_actor(actor, organisation): + participants = sorted(set(participants)) + session_id = exchange_id(participants, organisation) + digest = hashlib.sha256(json.dumps([actor, session_id]).encode()).hexdigest()[ + :32 + ] + concept_id = "#V#message_exchange_reference_" + digest + locator = { + "source_kind": "message_exchange", + "session_id": session_id, + "participant_ids": participants, + "organisation_concept_id": organisation, + "method": "POST /api/messages/exchange", + } + existing = read_reference(concept_id) + if not existing: + try: + create_concept( + name="Message conversation reference", + concept_id=concept_id, + parent_concept_ids=["#V#conversation"], + description="Private reference to an exact message exchange. Messages remain in their canonical store. Retrieve using: " + + json.dumps(locator, sort_keys=True), + attributes={"message_exchange_reference": locator}, + created_by_concept_id=actor, + organisation_concept_id=organisation, + visibility_scope_mode="user_only_default", + resolve_visibility_from_event_namespace=False, + maintain_relationship_inverses=False, + ) + except Exception: + # A concurrent copy may have created the same private reference. + if not read_reference(concept_id): + raise + verified = read_reference(concept_id) + if ( + not verified + or get_specific_to_user_values(verified.get("relationships")) != [actor] + or verified.get("attributes", {}).get("message_exchange_reference") + != locator + ): + raise RuntimeError("Conversation reference could not be verified.") + return {"concept_id": concept_id, "message_exchange_reference": locator} diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index c9b17b18d..55c46bae9 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -27583,7 +27583,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { } const isPinned = isConversationPinned(sid); if (session.source_kind === 'message_exchange') { - fragment.append(renderMessageConversationRow(session, { selected: sid === activeSessionId, pinned: isPinned, togglePin: () => toggleConversationPinned(sid), hide: () => hideConversation(sid) })); + fragment.append(renderMessageConversationRow(session, { selected: sid === activeSessionId, pinned: isPinned, togglePin: () => toggleConversationPinned(sid), hide: () => isConversationHidden(sid) ? unhideConversation(sid) : hideConversation(sid), hidden: isConversationHidden(sid), openMenu: openChatSessionMenu })); return; } const isFirstPinnedTab = pinnedSessions.length > 0 && index === 0 && isPinned; diff --git a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js index 344104ed5..d5d14b907 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js +++ b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js @@ -1,7 +1,10 @@ /** Message-source adapter for the existing conversation tray. */ +import { copyTextWithClipboardFallback } from '../utils/copyJsonButtonState.js'; +import { buildMessageStreamReference } from '../utils/messageStreamReference.js'; +import { showToast } from '../utils/toast.js'; import { getJson, postJson } from '../apiService.js'; import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js'; -import { participantAvatar, profileButton } from './participantProfile.js'; +import { participantAvatar, openParticipantProfile } from './participantProfile.js'; import { openMessageExchange, refreshOpenMessageExchange, showMessageComposer, resetMessagePanelContext } from './messagePanel.js'; let rows = []; @@ -104,9 +107,9 @@ export async function selectMessageConversation(row) { await openMessageExchange(row); } -export function renderMessageConversationRow(row, { selected = false, pinned = false, togglePin, hide } = {}) { - const el = document.createElement('button'); - el.type = 'button'; +export function renderMessageConversationRow(row, { selected = false, pinned = false, togglePin, hide, hidden = false, openMenu } = {}) { + const el = document.createElement('div'); + el.tabIndex = 0; el.className = `chat-session-tab message-conversation-row${selected ? ' is-active' : ''}${row.shared_unread_count ? ' has-unread' : ''}`; el.dataset.sessionId = row.session_id; el.dataset.trayInitial = Array.from(row.session_name || 'V')[0]; @@ -139,23 +142,68 @@ export function renderMessageConversationRow(row, { selected = false, pinned = f el.setAttribute('aria-label', el.title); el.append(header, meta, preview); el.addEventListener('click', () => void selectMessageConversation(row)); - el.addEventListener('contextmenu', event => { + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'conversation-menu-trigger'; + trigger.textContent = '⋯'; + trigger.setAttribute('aria-label', `Conversation actions for ${row.session_name}`); + trigger.setAttribute('aria-haspopup', 'menu'); + header.append(trigger); + const open = async event => { event.preventDefault(); - const menu = document.createElement('dialog'); - menu.className = 'participant-profile-dialog'; - const action = (label, fn) => { - const b = document.createElement('button'); b.type = 'button'; b.textContent = label; - b.onclick = () => { menu.close(); fn(); }; menu.append(b); - }; - action(pinned ? 'Unpin conversation' : 'Pin conversation', togglePin); - action('Hide conversation', hide); - row.participant_ids.forEach(id => menu.append(profileButton(id, profiles.get(id)?.display_name || id))); - action('Close', () => {}); - document.body.append(menu); menu.addEventListener('close', () => menu.remove()); menu.showModal(); + event.stopPropagation(); + const showMenu = openMenu || (await import('../chatTab.js')).openChatSessionMenu; + if (!el.isConnected) return; + const rect = trigger.getBoundingClientRect(); + showMenu(event.type === 'contextmenu' ? event.clientX : rect.left, + event.type === 'contextmenu' ? event.clientY : rect.bottom, + buildMessageConversationMenuItems(row, { pinned, togglePin, hide, hidden }), + { returnFocus: trigger, focusFirst: true }); + }; + trigger.addEventListener('click', open); + el.addEventListener('contextmenu', open); + el.addEventListener('keydown', event => { + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) void open(event); + else if (event.target === el && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); void selectMessageConversation(row); + } }); return el; } +export function buildMessageConversationMenuItems(row, { pinned, togglePin, hide, hidden } = {}) { + const items = [ + { label: 'Open conversation', onClick: () => void selectMessageConversation(row) }, + { label: 'Copy Concept ID', onClick: async () => { + try { + const result = await postJson('/api/messages/exchange/reference', { + participant_ids: row.participant_ids, + organisation_concept_id: row.organisation_concept_id ?? null, + }); + if (!result?.success || !result.concept_id?.startsWith('#V#')) throw new Error('Unavailable'); + const copied = await copyTextWithClipboardFallback(result.concept_id); + showToast(copied ? 'Copied conversation Concept ID.' : 'Failed to copy Concept ID.', copied ? 'success' : 'error'); + } catch (_) { showToast('Conversation Concept ID is unavailable. Try again.', 'error'); } + } }, + { label: 'Copy conversation reference', onClick: async () => { + const reference = buildMessageStreamReference({ + currentUserId: row.viewer_id, otherUserId: row.other_participant_ids[0] || row.viewer_id, + participantIds: row.participant_ids, organisationConceptId: row.organisation_concept_id, + displayName: row.session_name, + }); + const copied = reference && await copyTextWithClipboardFallback(JSON.stringify(reference, null, 2)); + showToast(copied ? 'Copied conversation reference.' : 'Failed to copy conversation reference.', copied ? 'success' : 'error'); + } }, + ]; + if (togglePin) items.push({ label: pinned ? 'Unpin conversation' : 'Pin conversation', onClick: togglePin }); + if (hide) items.push({ label: hidden ? 'Unhide conversation' : 'Hide conversation', onClick: hide }); + row.participant_ids.forEach(id => items.push({ + label: `Profile: ${profiles.get(id)?.display_name || id}`, + onClick: () => void openParticipantProfile(id), + })); + return items; +} + export function mountCatalogueControls(container) { const compose = document.createElement('button'); compose.type = 'button'; compose.className = 'chat-session-tab'; diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index cd6231c46..6f79f8f27 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -10561,6 +10561,11 @@ body[data-active-tab="settingsTab"] .tab-content-area { } .chat-session-menu { + box-sizing: border-box; + width: max-content; + max-width: min(320px, calc(100vw - 16px)); + max-height: calc(100dvh - 16px); + overflow-y: auto; position: fixed; z-index: 20050; min-width: 160px; @@ -10577,6 +10582,9 @@ body[data-active-tab="settingsTab"] .tab-content-area { } .chat-session-menu button { + white-space: normal; + overflow-wrap: anywhere; + min-height: 44px; width: 100%; text-align: left; background: transparent; @@ -18618,3 +18626,10 @@ body.settings-body { @media (prefers-reduced-motion: reduce) { .chat-scroll-to-end-btn { transition: none; } } + +.conversation-menu-trigger { + margin-left: auto; + flex: 0 0 44px; + min-height: 44px; + padding: 4px; +} diff --git a/tests/backend/test_message_exchange_catalogue.py b/tests/backend/test_message_exchange_catalogue.py index fddd52c7c..92c57986b 100644 --- a/tests/backend/test_message_exchange_catalogue.py +++ b/tests/backend/test_message_exchange_catalogue.py @@ -91,3 +91,78 @@ def test_deleted_messages_and_equal_timestamp_paging(collection): before=first["before"], ) assert [m["concept_id"] for m in next_page["messages"]] == [a["concept_id"]] + + +@pytest.fixture +def reference_store(monkeypatch): + from src.backend.services import concept_service + + store = {} + calls = [] + + def read(cid): + if cid not in store: + raise concept_service.ConceptNotFoundError(cid) + return store[cid] + + def create(**kwargs): + calls.append(kwargs) + scope = concept_service._resolve_creation_visibility_scope( + created_by_concept_id=kwargs['created_by_concept_id'], + organisation_concept_id=kwargs['organisation_concept_id'], + event_namespace=None, + visibility_scope_mode=kwargs['visibility_scope_mode'], + ) + kwargs['relationships'] = scope['relationships'] + store[kwargs["concept_id"]] = kwargs + return kwargs + + monkeypatch.setattr(concept_service, "get_concept_by_concept_id_exact", read) + monkeypatch.setattr(concept_service, "create_concept", create) + return store, calls + + +def test_reference_is_private_idempotent_and_retains_exact_locator( + collection, reference_store +): + collection.insert_one(message(1)) + store, calls = reference_store + first = catalogue.materialise_exchange_reference( + "#V#alice", ["#V#alice", "#V#bob"], organisation="#V#lab" + ) + again = catalogue.materialise_exchange_reference( + "#V#alice", ["#V#bob", "#V#alice"], organisation="#V#lab" + ) + assert first == again + assert len(calls) == 1 + assert calls[0]["created_by_concept_id"] == "#V#alice" + assert calls[0]["organisation_concept_id"] == "#V#lab" + assert calls[0]["resolve_visibility_from_event_namespace"] is False + assert first["message_exchange_reference"]["participant_ids"] == [ + "#V#alice", + "#V#bob", + ] + assert first["concept_id"] in store + assert store[first["concept_id"]]["relationships"] == {"#V#specific_to_user": ["#V#alice"]} + # Reuse must still prove that there is a currently visible source exchange. + collection.delete_many({}) + with pytest.raises(PermissionError): + catalogue.materialise_exchange_reference( + "#V#alice", ["#V#alice", "#V#bob"], organisation="#V#lab" + ) + + +def test_reference_denies_other_actor_group_and_organisation( + collection, reference_store +): + collection.insert_one(message(1)) + for actor, participants, org in [ + ("#V#eve", ["#V#alice", "#V#bob"], "#V#lab"), + ("#V#alice", ["#V#alice", "#V#bob", "#V#carol"], "#V#lab"), + ("#V#alice", ["#V#alice", "#V#bob"], "#V#other_lab"), + ]: + with pytest.raises(PermissionError): + catalogue.materialise_exchange_reference( + actor, participants, organisation=org + ) + assert not reference_store[1] diff --git a/tests/backend/test_message_routes.py b/tests/backend/test_message_routes.py index e68cd0306..b01833bb9 100644 --- a/tests/backend/test_message_routes.py +++ b/tests/backend/test_message_routes.py @@ -634,3 +634,52 @@ def test_personal_window_offers_shared_organisation_recovery(monkeypatch, app_cl assert response.status_code == 409 assert response.get_json()['error_code'] == 'message_organisation_required' assert response.get_json()['common_organisation_options'] == options + + +def test_exchange_reference_uses_authenticated_actor(monkeypatch, app_client): + from src.backend.services import message_catalogue_service + + _, client = app_client + _authorise_test_direct_message(monkeypatch) + calls = [] + + def materialise(actor, participants, *, organisation): + calls.append((actor, participants, organisation)) + return {"concept_id": "#V#reference_verified"} + + monkeypatch.setattr( + message_catalogue_service, "materialise_exchange_reference", materialise + ) + response = client.post( + "/api/messages/exchange/reference", + json={ + "actor": "#V#spoofed", + "participant_ids": ["#V#user_alice", "#V#user_bob"], + "organisation_concept_id": "#V#org_test", + }, + ) + assert response.status_code == 200 + assert response.json["concept_id"] == "#V#reference_verified" + assert calls == [("#V#user_alice", ["#V#user_alice", "#V#user_bob"], "#V#org_test")] + monkeypatch.setattr(message_routes, "_get_current_user_concept_id", lambda: None) + assert client.post("/api/messages/exchange/reference", json={}).status_code == 401 + assert len(calls) == 1 + + +def test_exchange_reference_denial_is_not_success(monkeypatch, app_client): + from src.backend.services import message_catalogue_service + + _, client = app_client + _authorise_test_direct_message(monkeypatch) + + def denied(*args, **kwargs): + raise PermissionError("Unavailable") + + monkeypatch.setattr( + message_catalogue_service, "materialise_exchange_reference", denied + ) + response = client.post( + "/api/messages/exchange/reference", json={"participant_ids": ["#V#user_alice"]} + ) + assert response.status_code == 403 + assert "concept_id" not in response.json diff --git a/tests/browser/messageConversationMenu.cjs b/tests/browser/messageConversationMenu.cjs new file mode 100644 index 000000000..59f1f9cb4 --- /dev/null +++ b/tests/browser/messageConversationMenu.cjs @@ -0,0 +1,65 @@ +// Production UI modules and stylesheet; isolated synthetic HTTP/clipboard fixture. +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 evidence = process.argv[2] || '.run/message-menu'; +fs.mkdirSync(evidence, { recursive: true }); +let references = 0; +const server = http.createServer((req, res) => { + if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end('
'); } + if (req.url.startsWith('/static/')) { + const file = path.resolve(root, `.${req.url}`); + if (file.startsWith(root + '/static/') && fs.existsSync(file)) { + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); return res.end(fs.readFileSync(file)); + } + } + res.setHeader('Content-Type', 'application/json'); + if (req.url.includes('/exchange/reference')) { references++; return res.end(JSON.stringify({ success: true, concept_id: '#V#message_exchange_reference_fixture' })); } + res.end(JSON.stringify({ success: true, authenticated: true, messages: [], profiles: [] })); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + try { + const results = []; + for (const width of [320, 448, 1280]) { + const page = await browser.newPage({ viewport: { width, height: 891 }, hasTouch: true }); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + window.copied = []; + Object.defineProperty(navigator, 'clipboard', { value: { writeText: async text => window.copied.push(text) } }); + const { renderMessageConversationRow } = await import('/static/js/components/conversationCatalogue.js'); + const { openChatSessionMenu } = await import('/static/js/chatTab.js'); + const row = { session_id: 'messages:fixture', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#participant_with_a_deliberately_long_concept_identifier_for_mobile_wrapping'], other_participant_ids: ['#V#bob'], session_name: 'Coding agent conversation', last_message_at: '2026-09-13', organisation_concept_id: '#V#lab' }; + document.querySelector('#chatSessionTabs').append(renderMessageConversationRow(row, { openMenu: openChatSessionMenu, togglePin: () => {}, hide: () => {} })); + }); + const trigger = page.getByRole('button', { name: 'Conversation actions for Coding agent conversation' }); + await trigger.tap(); + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + const geometry = await menu.evaluate(el => { const r = el.getBoundingClientRect(); return { left: r.left, right: r.right, width: r.width, overflow: el.scrollWidth - el.clientWidth }; }); + assert(geometry.left >= 0 && geometry.right <= width && geometry.overflow <= 1, JSON.stringify(geometry)); + await page.screenshot({ path: path.join(evidence, `menu-${width}.png`) }); + await page.getByRole('menuitem', { name: 'Copy Concept ID', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.copied[0])).toBe('#V#message_exchange_reference_fixture'); + await trigger.focus(); await page.keyboard.press('Shift+F10'); + await expect(menu).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); await expect(trigger).toBeFocused(); + await trigger.tap(); + await page.getByRole('menuitem', { name: 'Copy conversation reference', exact: true }).click(); + const reference = JSON.parse(await page.evaluate(() => window.copied[1])); + assert.equal(reference.message_lookup.organisation_concept_id, '#V#lab'); + assert.equal(reference.message_lookup.participant_ids.length, 2); + assert.deepEqual(errors, []); + results.push({ width, geometry, copyConceptId: true, copyReference: true, keyboardEscapeFocus: true }); + await page.close(); + } + assert.equal(references, 3); + fs.writeFileSync(path.join(evidence, 'browser-result.json'), JSON.stringify({ fixture: true, results }, null, 2)); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/frontend/messageConversationMenu.test.js b/tests/frontend/messageConversationMenu.test.js new file mode 100644 index 000000000..57d68b0e5 --- /dev/null +++ b/tests/frontend/messageConversationMenu.test.js @@ -0,0 +1,39 @@ +/** @jest-environment jsdom */ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), postJson: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/components/messagePanel.js', () => ({ openMessageExchange: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/components/participantProfile.js', () => ({ participantAvatar: () => global.document.createElement('span'), openParticipantProfile: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/copyJsonButtonState.js', () => ({ copyTextWithClipboardFallback: jest.fn(async () => true) })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/toast.js', () => ({ showToast: jest.fn() })); +const base = '../../src/frontend/web/von_interface/static/js/'; +const { renderMessageConversationRow, buildMessageConversationMenuItems } = require(base + 'components/conversationCatalogue.js'); +const { postJson } = require(base + 'apiService.js'); +const { copyTextWithClipboardFallback: copy } = require(base + 'utils/copyJsonButtonState.js'); +const row = { session_id: 'messages:exact', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], organisation_concept_id: '#V#lab', session_name: 'Bob', last_message_at: '2026-09-13' }; +beforeEach(() => { document.body.innerHTML = ''; jest.clearAllMocks(); }); +test('touch action and keyboard context key open the shared menu without selecting the conversation', () => { + const openMenu = jest.fn(); + const el = renderMessageConversationRow(row, { openMenu }); document.body.append(el); + el.querySelector('.conversation-menu-trigger').click(); + expect(openMenu).toHaveBeenCalledTimes(1); + expect(require(base + 'components/messagePanel.js').openMessageExchange).not.toHaveBeenCalled(); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true })); + expect(openMenu).toHaveBeenCalledTimes(2); + expect(openMenu.mock.calls[0][3]).toMatchObject({ focusFirst: true }); +}); +test('copies only the server-verified Concept ID and preserves exact group/organisation reference', async () => { + const group = { ...row, participant_ids: [...row.participant_ids, '#V#carol'] }; + const items = buildMessageConversationMenuItems(group, { pinned: true, hidden: true, togglePin: jest.fn(), hide: jest.fn() }); + postJson.mockResolvedValue({ success: true, concept_id: '#V#message_exchange_reference_verified' }); + await items.find(i => i.label === 'Copy Concept ID').onClick(); + expect(postJson).toHaveBeenCalledWith('/api/messages/exchange/reference', { participant_ids: group.participant_ids, organisation_concept_id: '#V#lab' }); + expect(copy).toHaveBeenLastCalledWith('#V#message_exchange_reference_verified'); + await items.find(i => i.label === 'Copy conversation reference').onClick(); + expect(JSON.parse(copy.mock.calls[1][0]).message_lookup).toEqual({ method: 'POST /api/messages/exchange', participant_ids: group.participant_ids, organisation_concept_id: '#V#lab' }); + expect(items.map(i => i.label)).toEqual(expect.arrayContaining(['Unpin conversation', 'Unhide conversation', 'Profile: #V#carol'])); +}); +test('failed materialisation never copies a fabricated identifier', async () => { + postJson.mockRejectedValue(new Error('Unavailable')); + await buildMessageConversationMenuItems(row).find(i => i.label === 'Copy Concept ID').onClick(); + expect(copy).not.toHaveBeenCalled(); + expect(require(base + 'utils/toast.js').showToast).toHaveBeenCalledWith(expect.stringContaining('unavailable'), 'error'); +});