feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings

A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 08:28:26 -04:00
parent 66c9851061
commit 17dbf076cb
19 changed files with 5237 additions and 524 deletions

View file

@ -1090,7 +1090,117 @@ function setupMyPostsMediaObserver() {
mutObs.observe(myPostsList, { childList: true });
}
// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss]
// ONLY — vouching is People-tab relationship management and must never be
// reachable from a Messages surface (round 9). The sender's name opens
// the ordinary bio modal, where the existing Friend flow lives.
let _greetingsCount = 0;
let _lastDmUnread = 0;
async function loadGreetings() {
const container = document.getElementById('greetings-list');
if (!container) return;
try {
const greetings = await invoke('list_greetings');
_greetingsCount = greetings.length;
// Notify once per greeting (localStorage-backed seen set, tag
// prefix `greet-` mirroring `msg-`).
try {
let notified = [];
try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {}
const notifSet = new Set(notified);
const keys = [];
for (const g of greetings) {
const key = `greet-${g.commentAuthor}-${g.timestampMs}`;
keys.push(key);
if (_notifReady && !notifSet.has(key)) {
const who = g.senderName || g.senderPersonaId.slice(0, 8);
maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key);
}
}
// Keep only keys still in the inbox (dismissed/expired drop out).
localStorage.setItem('greetNotified', JSON.stringify(keys));
} catch (_) {}
if (greetings.length === 0) {
container.innerHTML = `<p class="empty-hint">No greetings yet</p>`;
} else {
container.innerHTML = greetings.map(g => {
const icon = generateIdenticon(g.senderPersonaId, 18);
const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12));
return `<div class="peer-card greeting-item" data-comment-author="${g.commentAuthor}" data-post-id="${g.postId}" data-ts="${g.timestampMs}">
<div class="peer-card-row">${icon} <a class="peer-name-link greet-bio-link" data-node-id="${g.senderPersonaId}" data-name="${label}">${label}</a> <span class="last-seen">${formatTimeAgo(g.timestampMs)}</span></div>
<div style="font-size:0.85rem;line-height:1.4;margin-top:0.25rem;white-space:pre-wrap;word-break:break-word">${escapeHtml(g.content)}</div>
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm greet-reply-btn">Reply</button>
<button class="btn btn-danger btn-sm greet-dismiss-btn">Dismiss</button>
</div>
<div class="greet-reply-box hidden" style="margin-top:0.4rem;display:flex;gap:0.4rem;align-items:flex-end">
<textarea class="conv-reply-input" rows="2" maxlength="600" placeholder="Sealed reply..." style="flex:1"></textarea>
<button class="btn btn-primary btn-sm greet-reply-send">Send</button>
</div>
</div>`;
}).join('');
container.querySelectorAll('.greet-bio-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
container.querySelectorAll('.greet-reply-btn').forEach(btn => {
btn.addEventListener('click', () => {
const box = btn.closest('.greeting-item').querySelector('.greet-reply-box');
box.classList.toggle('hidden');
if (!box.classList.contains('hidden')) box.querySelector('textarea').focus();
});
});
container.querySelectorAll('.greet-reply-send').forEach(btn => {
btn.addEventListener('click', async () => {
const item = btn.closest('.greeting-item');
const input = item.querySelector('.greet-reply-box textarea');
const content = input.value.trim();
if (!content) return;
btn.disabled = true;
try {
await invoke('reply_to_greeting', {
commentAuthorHex: item.dataset.commentAuthor,
postIdHex: item.dataset.postId,
timestampMs: Number(item.dataset.ts),
content,
});
toast('Reply sent (sealed)');
input.value = '';
item.querySelector('.greet-reply-box').classList.add('hidden');
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
container.querySelectorAll('.greet-dismiss-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const item = btn.closest('.greeting-item');
btn.disabled = true;
try {
await invoke('dismiss_greeting', {
commentAuthorHex: item.dataset.commentAuthor,
postIdHex: item.dataset.postId,
timestampMs: Number(item.dataset.ts),
});
loadGreetings();
} catch (e) { toast('Error: ' + e); btn.disabled = false; }
});
});
}
updateTabBadge('messages', _lastDmUnread + _greetingsCount);
} catch (e) {
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
async function loadMessages(force) {
// v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call,
// fire-and-forget — it updates the shared Messages badge itself).
loadGreetings().catch(() => {});
try {
const [posts, follows] = await Promise.all([
invoke('get_all_posts'),
@ -1336,7 +1446,9 @@ async function loadMessages(force) {
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
if (hasUnread) unreadCount++;
}
updateTabBadge('messages', unreadCount);
// v0.8 (A3): the Messages badge = unread DMs + undismissed greetings.
_lastDmUnread = unreadCount;
updateTabBadge('messages', unreadCount + _greetingsCount);
} catch (e) {
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
@ -1669,7 +1781,7 @@ async function openBioModal(nodeId, preloadedName) {
overlay.classList.remove('hidden');
try {
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
// `resolve_display` returns {displayName, bio, avatarCid} for any NodeId.
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
const follows = await invoke('list_follows').catch(() => []);
const ignored = await invoke('list_ignored_peers').catch(() => []);
@ -1677,7 +1789,7 @@ async function openBioModal(nodeId, preloadedName) {
const following = follows.some(f => f.nodeId === nodeId);
const isIgnored = ignored.some(i => i.nodeId === nodeId);
const isVouched = vouches.some(v => v.nodeId === nodeId);
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12);
const bio = (resolved && resolved.bio) || '';
const icon = generateIdenticon(nodeId, 48);
@ -1686,12 +1798,12 @@ async function openBioModal(nodeId, preloadedName) {
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
${icon}
<div style="flex:1;min-width:0">
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div id="bio-modal-name" style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
</div>
</div>
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
${bio ? `<p id="bio-modal-bio" style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p id="bio-modal-bio" class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div id="bio-actions" style="display:flex;gap:0.4rem;flex-wrap:wrap">
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
${(following && isVouched)
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
@ -1785,11 +1897,90 @@ async function openBioModal(nodeId, preloadedName) {
} catch (e) { toast('Error: ' + e); }
finally { unfriend.disabled = false; }
};
// v0.8 (A3): greeting affordance for strangers only (no existing
// relationship). The slot check may fetch the bio on-demand, so
// the button arrives asynchronously. Established peers keep the
// ordinary Message button.
if (!following && !isVouched) {
invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => {
const row = document.getElementById('bio-actions');
if (!row || !bodyEl.contains(row)) return; // modal replaced/closed
// The slot check may have pulled the author's posts on-demand
// (registry search results start non-local) — if the first
// resolve came back empty, patch name/bio in place now.
if (!resolved || (!resolved.displayName && !resolved.bio)) {
const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
if (again && bodyEl.contains(row)) {
if (again.displayName) {
titleEl.textContent = again.displayName;
const nm = document.getElementById('bio-modal-name');
if (nm) nm.textContent = again.displayName;
}
if (again.bio) {
const bp = document.getElementById('bio-modal-bio');
if (bp) {
bp.textContent = again.bio;
bp.className = '';
bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem';
}
}
}
}
if (slot && slot.acceptsGreetings) {
const hi = document.createElement('button');
hi.id = 'bio-say-hi';
hi.className = 'btn btn-primary btn-sm';
hi.textContent = 'Say hi';
row.prepend(hi);
hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name);
} else {
const na = document.createElement('button');
na.className = 'btn btn-ghost btn-sm';
na.disabled = true;
na.textContent = 'Not accepting greetings';
row.appendChild(na);
}
}).catch(() => {});
}
} catch (e) {
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed
// one-shot compose aimed at the bio post's Greeting open slot.
function openGreetingCompose(bioPostIdHex, name) {
const titleEl = document.getElementById('popover-title');
const bodyEl = document.getElementById('popover-body');
if (!titleEl || !bodyEl) return;
titleEl.textContent = `Say hi to ${name}`;
bodyEl.innerHTML = `
<textarea id="greeting-text" rows="4" maxlength="600" placeholder="Introduce yourself..." style="width:100%;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:vertical;font-family:inherit;font-size:0.9rem"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem">
<span class="hint">Sealed &mdash; only ${escapeHtml(name)} can read this</span>
<button id="greeting-send-btn" class="btn btn-primary btn-sm">Send</button>
</div>`;
const input = document.getElementById('greeting-text');
setTimeout(() => input.focus(), 100);
const sendBtn = document.getElementById('greeting-send-btn');
const send = async () => {
const content = input.value.trim();
if (!content) return;
sendBtn.disabled = true;
try {
await invoke('send_greeting', { bioPostIdHex, content });
toast('Greeting sent');
closePopover();
} catch (e) {
toast('Error: ' + e);
sendBtn.disabled = false;
}
};
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); });
}
function openAuthorFeed(nodeId, name) {
authorFilterNodeId = nodeId;
authorFilterName = name || nodeId.slice(0, 12);
@ -2003,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) {
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Quotes MUST be escaped too: escaped output is interpolated into
// double-quoted HTML attributes (data-name="..."), and registry
// entries / greeting sender names are attacker-controlled — a name
// like `x" onclick="..."` would otherwise break out of the attribute
// and inject an inline handler (stored XSS).
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function formatTimeAgo(timestampMs) {
@ -2827,11 +3024,41 @@ async function doSyncAll() {
}
}
// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox
// and first-run consent act on. Falls back to the first-run auto persona.
async function getDefaultPersonaId() {
try {
const ids = await invoke('list_posting_identities');
const def = ids.find(p => p.isDefault) || ids[0];
if (def) return def.nodeId;
} catch (_) {}
try {
const auto = await invoke('get_first_run_auto_persona_id');
if (auto) return auto;
} catch (_) {}
return null;
}
async function doSetupName() {
// Name is optional — users who want to stay anonymous can proceed with a blank field.
const name = setupName.value.trim();
setupBtn.disabled = true;
try {
// v0.8 (A3): active choice at first publish — if the pre-checked
// "Accept greetings" box was unticked, record the opt-out BEFORE
// the first bio publish so no greeting slot ever ships (the
// republish inside set_display_name reads this setting).
const greetCheck = document.getElementById('setup-greetings-check');
if (greetCheck && !greetCheck.checked) {
// The opt-out write is LOAD-BEARING (round 8: an explicit
// opt-out must never silently fail and ship the slot anyway).
// Any failure here aborts the publish; the overlay stays up.
const personaId = await getDefaultPersonaId();
if (!personaId) {
throw new Error('could not resolve your persona to record the greeting opt-out — please try again');
}
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' });
}
await invoke('set_display_name', { name });
setupOverlay.classList.add('hidden');
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
@ -3180,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
clearNotifications('greet-');
}
if (target === 'settings') {
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
@ -3216,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
Show my profile to non-circle peers
</label>
<label class="checkbox-label" style="margin:0.5rem 0 0.25rem;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-registry-listed" />
List me in the network registry (searchable by anyone)
</label>
<input id="lb-registry-keywords" type="text" placeholder="Keywords, comma-separated (up to 8)" maxlength="280" style="width:100%;margin-bottom:0.25rem" />
<div id="lb-registry-status" class="empty-hint" style="font-size:0.72rem;margin-bottom:0.5rem">Not listed</div>
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-greetings-open" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
</div>
@ -3228,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#lb-profile-save').addEventListener('click', async () => {
// v0.8 (A3): prefill the consent panel (registry listing + greeting
// consent) for the default persona. Status line mirrors auto-renew.
// The Save button stays DISABLED until the prefill settles: a listed
// user clicking Save against the unfilled (unchecked) checkbox would
// otherwise be treated as never-listed — their unlist intent dropped
// and auto-renew silently continuing.
let _lbWasListed = false;
const _lbSaveBtn = overlay.querySelector('#lb-profile-save');
_lbSaveBtn.disabled = true;
(async () => {
try {
const personaId = await getDefaultPersonaId();
if (!personaId || !document.body.contains(overlay)) return;
try {
const status = await invoke('get_registry_status', { postingIdHex: personaId });
_lbWasListed = !!status.listed;
const listedCheck = overlay.querySelector('#lb-registry-listed');
const kwInput = overlay.querySelector('#lb-registry-keywords');
const statusEl = overlay.querySelector('#lb-registry-status');
if (listedCheck) listedCheck.checked = _lbWasListed;
if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', ');
if (statusEl) statusEl.textContent = _lbWasListed
? 'Listed — auto-renews every 30 days while checked'
: 'Not listed';
} catch (_) {}
try {
const open = await invoke('get_greetings_open', { postingIdHex: personaId });
const greetCheck = overlay.querySelector('#lb-greetings-open');
if (greetCheck) greetCheck.checked = !!open;
} catch (_) {}
} finally {
_lbSaveBtn.disabled = false;
}
})();
_lbSaveBtn.addEventListener('click', async () => {
const name = overlay.querySelector('#lb-profile-name').value.trim();
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
if (!name) { toast('Display name is required'); return; }
try {
const personaId = await getDefaultPersonaId();
// v0.8 (A3): write greeting consent BEFORE set_profile so the
// single bio republish below carries the right slot state
// (avoids a second republish via set_greetings_open). The
// write is LOAD-BEARING (round 8): if it fails, abort the
// publish rather than republishing with the wrong slot state.
const greetOpen = overlay.querySelector('#lb-greetings-open').checked;
if (!personaId) {
throw new Error('could not resolve your persona — profile not saved, please try again');
}
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' });
await invoke('set_profile', { name, bio });
const visible = overlay.querySelector('#lb-public-visible').checked;
await invoke('set_public_visible', { visible });
// v0.8 (A3): registry listing. Checked = (re-)register — the
// core renews automatically every ~25 days while listed.
// Unchecking sends the self-certifying signed delete.
if (personaId) {
const listed = overlay.querySelector('#lb-registry-listed').checked;
const keywords = overlay.querySelector('#lb-registry-keywords').value
.split(',').map(k => k.trim()).filter(k => k).slice(0, 8);
if (listed) {
await invoke('register_persona', { postingIdHex: personaId, name, keywords });
} else if (_lbWasListed) {
await invoke('unregister_persona', { postingIdHex: personaId });
}
}
// Sync back to settings fields
profileNameInput.value = name;
profileBioInput.value = bio;
@ -3297,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => {
if (!body.classList.contains('hidden')) loadDiscoverPeople();
});
// v0.8 (A3): registry search — pulls the registry comment chain from
// peers, then queries locally. Results show name/keywords only; the bio
// is fetched on-demand when a result is clicked (openBioModal).
async function runRegistrySearch() {
const input = $('#registry-search-input');
const results = $('#registry-results');
const btn = $('#registry-search-btn');
const query = input.value.trim();
if (!query) { results.innerHTML = ''; return; }
btn.disabled = true;
results.innerHTML = renderLoading();
try {
const entries = await invoke('search_registry', { query });
if (!entries || entries.length === 0) {
results.innerHTML = renderEmptyState(
'No registry matches',
'Entries expire after 30 days — try different keywords.'
);
return;
}
results.innerHTML = `<h4 class="subsection-title" style="margin:0 0 0.4rem">Registry results</h4>` + entries.map(e => {
const icon = generateIdenticon(e.authorId, 18);
const label = escapeHtml(e.displayName);
const kwLine = e.keywords && e.keywords.length
? `<div class="peer-card-meta">${e.keywords.map(k => escapeHtml(k)).join(' &middot; ')}</div>`
: '';
const ageLine = e.updatedAtMs
? `<div class="peer-card-lastseen"><span class="last-seen">Listed ${formatTimeAgo(e.updatedAtMs)}</span></div>`
: '';
return `<div class="peer-card" data-node-id="${e.authorId}">
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${e.authorId}" data-name="${label}">${label}</a></div>
${kwLine}
${ageLine}
</div>`;
}).join('');
results.querySelectorAll('.bio-link').forEach(el => {
el.addEventListener('click', (ev) => {
ev.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
} catch (e) {
results.innerHTML = `<p class="status-err">Error: ${e}</p>`;
} finally {
btn.disabled = false;
}
}
$('#registry-search-btn').addEventListener('click', runRegistrySearch);
$('#registry-search-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') runRegistrySearch();
});
// "See new activity" button in Following: applies staged data and
// re-renders so the user picks when to rearrange the list.
{
@ -3977,15 +4325,46 @@ function renderPersonasList() {
const deleteBtn = p.isDefault
? ''
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
// Round 8: greeting consent is per-persona REVOCABLE. This toggle
// is the revocation path for non-default personas (the Profiles
// lightbox only manages the default persona). Label filled async.
const greetBtn = `<button class="btn btn-ghost btn-sm persona-greetings-btn" data-id="${p.nodeId}" style="font-size:0.65rem" disabled>Greetings: ...</button>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
<div style="min-width:0;flex:1">
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${setDefaultBtn}${deleteBtn}</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${greetBtn}${setDefaultBtn}${deleteBtn}</div>
</div>`;
}).join('');
list.querySelectorAll('.persona-greetings-btn').forEach(btn => {
// Fill the current consent state, then enable the toggle.
(async () => {
try {
const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id });
btn.dataset.open = open ? '1' : '0';
btn.textContent = open ? 'Greetings: on' : 'Greetings: off';
btn.disabled = false;
} catch (_) {
btn.textContent = 'Greetings: ?';
}
})();
btn.addEventListener('click', async () => {
const next = btn.dataset.open !== '1';
btn.disabled = true;
try {
await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next });
btn.dataset.open = next ? '1' : '0';
btn.textContent = next ? 'Greetings: on' : 'Greetings: off';
toast(next
? 'Greetings enabled — bio republished with a greeting slot'
: 'Greetings disabled — slot revoked on published bios');
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
@ -4040,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.75rem;margin-bottom:0.75rem">
<input type="checkbox" id="new-persona-greetings" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
@ -4052,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
try {
await invoke('create_posting_identity', { displayName: name });
// Round 8: greeting consent is an ACTIVE pre-checked choice at
// first publish. The choice rides the create call itself so the
// consent setting is written BEFORE the initial bio publish —
// an opted-out persona never ships a greeting slot at all.
const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked;
await invoke('create_posting_identity', { displayName: name, greetingsOpen });
toast(`Persona created: ${name}`);
overlay.remove();
loadPersonas();
@ -4346,7 +4734,7 @@ async function init() {
// Update tab badges from welcome screen
updateTabBadge('feed', b.newFeed || 0);
updateTabBadge('myposts', b.newEngagement || 0);
updateTabBadge('messages', b.unreadMessages || 0);
updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0));
// Ticker + notifications only after user leaves welcome screen
// (welcome page already shows these counts directly)
}).catch(() => {});
@ -4500,6 +4888,7 @@ async function init() {
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0));
} catch (_) {}
}, 30000);