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
16 changes: 16 additions & 0 deletions docs/engineering/unified_conversations_and_participant_profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions src/backend/server/routes/message_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions src/backend/services/message_catalogue_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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';
Expand Down
15 changes: 15 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
75 changes: 75 additions & 0 deletions tests/backend/test_message_exchange_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading
Loading