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:
parent
a41b11c0b8
commit
b7f2d369fa
9 changed files with 882 additions and 4 deletions
|
|
@ -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">✓</span>';
|
||||
} else if (bestState === 'seen') {
|
||||
el.innerHTML = '<span class="receipt-check seen" title="Seen">✓✓</span>';
|
||||
} else if (bestState === 'reacted') {
|
||||
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '✓✓')}</span>`;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDmRecipientOptions() {
|
||||
try {
|
||||
const [follows, peers] = await Promise.all([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue