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([