v0.3.5: Encrypted receipt & comment slots, message delivery indicators

Encrypted slots in BlobHeader:
- Private posts get noise-prefilled receipt slots (64B, 1 per participant)
  and comment slots (256B, ceil(participants/3), expandable)
- Slot key derived from post CEK via BLAKE3 — only participants can read
- CDN relays propagate opaque encrypted bytes without decryption
- 3 new BlobHeaderDiffOps: WriteReceiptSlot, WriteCommentSlot, AddCommentSlots

Receipt system:
- States: empty(0), delivered(1), seen(2), reacted(3)
- Slot index = position in sorted participant NodeId list
- Author can pre-feed emoji reaction at creation time
- 6 new crypto tests for slot encrypt/decrypt/derivation

Node methods:
- write_receipt_slot, write_comment_slot with upstream+downstream propagation
- read_receipt_slots, read_comment_slots with CEK-based decryption
- get_post_cek_and_participants helper for both Encrypted and GroupEncrypted

IPC: write_message_receipt, write_message_comment, get_message_receipts,
     get_message_comments

Frontend:
- DM chat bubbles show delivery indicators (check → double → blue → emoji)
- Opening conversation auto-marks incoming messages as seen
- React button on messages with emoji prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 14:15:33 -04:00
parent a41b11c0b8
commit b7f2d369fa
9 changed files with 882 additions and 4 deletions

View file

@ -843,9 +843,12 @@ async function loadMessages(force) {
const content = p.decryptedContent || p.content || '';
const msgTime = relativeTime(p.timestampMs);
const side = p.isMe ? 'chat-mine' : 'chat-theirs';
return `<div class="chat-bubble ${side}">
const isEncrypted = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
const receiptAttr = isEncrypted ? ` data-post-id="${p.id}"` : '';
const reactBtn = isEncrypted ? `<button class="slot-react-btn" data-post-id="${p.id}" title="React">+</button>` : '';
return `<div class="chat-bubble ${side}"${receiptAttr}>
<div class="chat-text">${escapeHtml(content)}</div>
<div class="chat-time">${msgTime}</div>
<div class="chat-meta"><span class="chat-time">${msgTime}</span>${p.isMe && isEncrypted ? '<span class="receipt-indicator" data-post-id="' + p.id + '"></span>' : ''}${reactBtn}</div>
</div>`;
}).join('');
@ -872,8 +875,15 @@ async function loadMessages(force) {
const msgsHtml = item.querySelector('.chat-window').innerHTML;
const partnerName = item.querySelector('.conv-name').textContent;
// Collect post IDs for receipt/seen tracking
const threadPostIds = thread.posts.filter(p => {
const enc = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
return enc;
}).map(p => p.id);
openPopover(partnerName, `
<div class="chat-window" style="max-height:55vh;overflow-y:auto;display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem 0">${msgsHtml}</div>
<div id="popover-slot-comments" style="max-height:15vh;overflow-y:auto;padding:0.3rem 0;border-top:1px solid #2a2a40;display:none"></div>
<div class="conv-reply" style="display:flex;gap:0.4rem;align-items:flex-end;margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #2a2a40">
<textarea class="conv-reply-input" id="popover-reply-input" placeholder="Reply..." rows="2" style="flex:1;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:none;font-family:inherit;font-size:0.85rem;min-height:36px;line-height:1.4"></textarea>
<button class="btn btn-primary btn-sm" id="popover-reply-btn">Send</button>
@ -886,6 +896,34 @@ async function loadMessages(force) {
// Focus reply
const input = $('#popover-reply-input');
if (input) setTimeout(() => input.focus(), 100);
// Mark incoming encrypted messages as "seen"
for (const p of thread.posts) {
if (!p.isMe && threadPostIds.includes(p.id)) {
invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {});
}
}
// Load receipt indicators for sent messages
loadReceiptIndicators(chatWindow);
// Wire react buttons
chatWindow.querySelectorAll('.slot-react-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pid = btn.dataset.postId;
const emoji = prompt('Emoji reaction:');
if (emoji && emoji.trim()) {
try {
await invoke('write_message_receipt', { postId: pid, receiptState: 'reacted', emoji: emoji.trim() });
toast('Reacted!');
} catch (err) {
toast('Error: ' + err);
}
}
});
});
// Wire send
const sendReply = async () => {
const content = input.value.trim();
@ -928,6 +966,36 @@ async function loadMessages(force) {
}
}
// Load receipt indicators (checkmarks) for sent messages in a chat window
async function loadReceiptIndicators(chatWindow) {
if (!chatWindow) return;
const indicators = chatWindow.querySelectorAll('.receipt-indicator[data-post-id]');
for (const el of indicators) {
const postId = el.dataset.postId;
try {
const receipts = await invoke('get_message_receipts', { postId });
if (!receipts || receipts.length === 0) continue;
// Find the best state among non-self receipts
let bestState = 'empty';
let reactionEmoji = null;
for (const r of receipts) {
if (r.nodeId && r.nodeId !== myNodeId) {
if (r.state === 'reacted') { bestState = 'reacted'; reactionEmoji = r.emoji; break; }
if (r.state === 'seen' && bestState !== 'reacted') bestState = 'seen';
if (r.state === 'delivered' && bestState === 'empty') bestState = 'delivered';
}
}
if (bestState === 'delivered') {
el.innerHTML = '<span class="receipt-check" title="Delivered">&#10003;</span>';
} else if (bestState === 'seen') {
el.innerHTML = '<span class="receipt-check seen" title="Seen">&#10003;&#10003;</span>';
} else if (bestState === 'reacted') {
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '&#10003;&#10003;')}</span>`;
}
} catch (_) {}
}
}
async function loadDmRecipientOptions() {
try {
const [follows, peers] = await Promise.all([

View file

@ -286,8 +286,16 @@ header h1 { font-size: 1.4rem; color: #7fdbca; }
.chat-mine { align-self: flex-end; background: #0f3460; color: #e0e0e0; border-bottom-right-radius: 4px; }
.chat-theirs { align-self: flex-start; background: #1e2040; color: #dde; border-bottom-left-radius: 4px; }
.chat-text { white-space: pre-wrap; }
.chat-time { font-size: 0.6rem; color: #778; margin-top: 0.2rem; text-align: right; }
.chat-meta { display: flex; align-items: center; gap: 0.3rem; justify-content: flex-end; margin-top: 0.15rem; }
.chat-theirs .chat-meta { justify-content: flex-start; }
.chat-time { font-size: 0.6rem; color: #778; text-align: right; }
.chat-theirs .chat-time { text-align: left; }
.receipt-indicator { display: inline-block; min-width: 1em; }
.receipt-check { font-size: 0.65rem; color: #778; letter-spacing: -0.15em; }
.receipt-check.seen { color: #7fdbca; }
.receipt-check.reacted { color: #f0a; letter-spacing: 0; }
.slot-react-btn { background: none; border: 1px solid #444; color: #aab; border-radius: 10px; font-size: 0.6rem; padding: 0 0.3rem; cursor: pointer; line-height: 1.4; opacity: 0; transition: opacity 0.15s; }
.chat-bubble:hover .slot-react-btn { opacity: 1; }
.conv-reply { display: flex; gap: 0.4rem; align-items: flex-end; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid #2a2a40; }
.conv-reply-input { flex: 1; padding: 0.4rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; resize: none; font-family: inherit; font-size: 0.85rem; min-height: 36px; line-height: 1.4; transition: border-color 0.15s; }