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:
parent
66c9851061
commit
17dbf076cb
19 changed files with 5237 additions and 524 deletions
411
frontend/app.js
411
frontend/app.js
|
|
@ -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 — 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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(' · ')}</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);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@
|
|||
<h2>Welcome to ItsGoin</h2>
|
||||
<p>Pick a display name if you want one — or leave blank to stay anonymous.</p>
|
||||
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
|
||||
<!-- v0.8 (A3): active choice at first profile publish — pre-checked,
|
||||
visible, opt-out BEFORE anything ships (never silently defaulted). -->
|
||||
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.8rem;margin:0.5rem 0 0.75rem;cursor:pointer">
|
||||
<input type="checkbox" id="setup-greetings-check" checked />
|
||||
Accept greetings — people who find you can send a sealed hello. Only you can read them. Turn off any time in Profiles.
|
||||
</label>
|
||||
<button id="setup-btn" class="btn btn-primary">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -139,8 +145,14 @@
|
|||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
|
||||
<div id="discover-body" class="hidden">
|
||||
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Hide Discover</button>
|
||||
<div id="discover-body">
|
||||
<!-- v0.8 (A3): registry search — on-demand chain pull, local query -->
|
||||
<div class="input-row" style="margin-bottom:0.5rem">
|
||||
<input id="registry-search-input" placeholder="Search the registry: name or keywords" />
|
||||
<button id="registry-search-btn" class="btn btn-primary">Search</button>
|
||||
</div>
|
||||
<div id="registry-results"></div>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
|
||||
<div id="discover-list"></div>
|
||||
</div>
|
||||
|
|
@ -181,6 +193,15 @@
|
|||
<h3>Message Requests</h3>
|
||||
<div id="message-requests-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- v0.8 (A3): greetings inbox — [Reply] and [Dismiss] ONLY.
|
||||
Vouching is People-tab relationship management, never a
|
||||
Messages action (round 9). -->
|
||||
<div class="section-card" id="greetings-section">
|
||||
<h3>Greetings</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Sealed hellos from people outside your circles. Replies go back sealed — only the sender can read them.</p>
|
||||
<div id="greetings-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings tab -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue