From bfd54209816beb46106d06ef1a149bf6e7338189 Mon Sep 17 00:00:00 2001 From: th Date: Wed, 15 Apr 2026 19:03:18 +0200 Subject: [PATCH] relay: surface shared-bucket activity in compose sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay UI already shows a tenant's inbox + sent mails. Add a parallel view for collaborative bucket cards the logged-in tenant is a member of, so activity on shared boards is visible without needing the hyprcrd client open. New `GET /api/collab-inbox` endpoint (session-auth only) decrypts payloads with the inter-tenant key and returns title + preview + author + bucket metadata. UI restructures the compose view into editor + right sidebar stacking inbox → shared → sent, and polls all three on the existing 15s tick. Co-Authored-By: Claude Opus 4.6 (1M context) --- relay/inc/routes/collab-cards.php | 41 +++++++++++++++++++++++++++++ relay/ui/app.js | 43 ++++++++++++++++++++++++++++++- relay/ui/layout.php | 26 ++++++++++++------- relay/ui/style.css | 8 +++++- 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/relay/inc/routes/collab-cards.php b/relay/inc/routes/collab-cards.php index 6a85630..f940db8 100644 --- a/relay/inc/routes/collab-cards.php +++ b/relay/inc/routes/collab-cards.php @@ -105,6 +105,47 @@ if ($route === '/api/collab-cards' && $method === 'POST') { // ── GET: pull deltas since a given timestamp ───────────────────── +// ── Session-auth summary for the relay UI ───────────────────── +// Lets the relay's web UI surface collab bucket activity to the +// logged-in tenant without exposing the per-client encryption dance: +// decrypts payloads with the inter-tenant key (we're authorizing via +// the PHP session, so there's no per-request client secret). Returns +// just title + small body preview + author + bucket so the UI can list +// recent activity like an inbox. +if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) { + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); + $stmt = $db->prepare(' + SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted, + b.name AS bucket_name, b.color AS bucket_color + FROM collab_cards c + INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid + LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid + WHERE m.tenant_id = :tid AND c.deleted = 0 + ORDER BY c.updated_at DESC + LIMIT 200 + '); + $stmt->execute(['tid' => $sessionTenant['id']]); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $plain = clientDecrypt($row['payload'] ?? '', $interKey); + $fields = $plain ? (json_decode($plain, true) ?: []) : []; + $body = (string)($fields['body'] ?? ''); + $out[] = [ + 'bucket_uid' => $row['bucket_uid'], + 'bucket_name' => $row['bucket_name'], + 'bucket_color' => $row['bucket_color'] ?? '#38bdf8', + 'card_uid' => $row['card_uid'], + 'author_username' => $row['author_username'], + 'title' => (string)($fields['title'] ?? ''), + 'preview' => mb_substr(trim(strip_tags($body)), 0, 140), + 'updated_at' => $row['updated_at'], + 'mine' => $row['author_username'] === $sessionTenant['username'], + ]; + } + jsonResponse(['ok' => true, 'items' => $out]); +} + if ($route === '/api/collab-cards' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429); diff --git a/relay/ui/app.js b/relay/ui/app.js index 0754255..d136b8d 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -93,6 +93,7 @@ function showView(name) { loadTenants().then(() => populateRecipientPicker()); loadInbox(); loadOutbox(); + loadCollabInbox(); startInboxPoll(); $('compose-body')?.focus(); } else { @@ -126,7 +127,11 @@ function startInboxPoll() { stopInboxPoll(); inboxPollHandle = setInterval(() => { if (inboxBusy) return; - if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); } + if (!$('view-compose')?.classList.contains('hidden')) { + loadInbox({ quiet: true }); + loadOutbox({ quiet: true }); + loadCollabInbox({ quiet: true }); + } }, 15000); } function stopInboxPoll() { @@ -594,6 +599,42 @@ async function loadOutbox({ quiet = false } = {}) { `).join(''); } +// ── Shared buckets / collab inbox ── +let lastCollabJson = ''; +async function loadCollabInbox({ quiet = false } = {}) { + const list = $('shared-list'); + const countEl = $('shared-count'); + if (!list) return; + const res = await api('api/collab-inbox'); + const items = res?.items || []; + const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at)); + if (quiet && json === lastCollabJson) return; + lastCollabJson = json; + if (countEl) countEl.textContent = items.length; + if (!items.length) { + list.innerHTML = ` +
+ +
no shared bucket activity
+
Cards from collaborative buckets that you're a member of will appear here.
+
`; + return; + } + list.innerHTML = items.map(c => ` +
+
+
+ ${escapeHtml(c.bucket_name || c.bucket_uid)} + ${c.mine ? 'you' : escapeHtml(c.author_username || '—')} +
+
${c.title ? escapeHtml(c.title) : '(untitled)'}
+ ${c.preview ? `
${escapeHtml(c.preview)}
` : ''} +
+
${escapeHtml(formatRelTime(c.updated_at))}
+
+ `).join(''); +} + async function deleteMessage(id, btn, source) { inboxBusy = true; // Inline confirmation: replace the trash button with yes/no. diff --git a/relay/ui/layout.php b/relay/ui/layout.php index e0292a5..6dcce64 100644 --- a/relay/ui/layout.php +++ b/relay/ui/layout.php @@ -105,16 +105,22 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
-
-

your inbox 0 - - -

-
-
-
-

sent 0

-
+
+
+

your inbox 0 + + +

+
+
+
+

shared buckets 0

+
+
+
+

sent 0

+
+
diff --git a/relay/ui/style.css b/relay/ui/style.css index 148c02f..a3560b7 100644 --- a/relay/ui/style.css +++ b/relay/ui/style.css @@ -687,13 +687,19 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; } border-radius: var(--radius); } - /* Compose view: 2-column grid */ + /* Compose view: 2 columns. Left is the compose editor. Right is a + sidebar with inbox → shared → sent stacked vertically. */ #view-compose:not(.hidden) { display: grid !important; grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); gap: 24px; align-items: start; } + #compose-sidebar { + display: flex; + flex-direction: column; + gap: 16px; + } /* Cards: depth via shadow + larger padding */ .compose-card,