Phase 5 (0.6.4-beta) frontend: Personas UI + compose picker + feed pills

Settings > Personas:
- List all held posting identities with display_name + truncated nodeId
- Default badge; Set-default / Delete buttons per non-default persona
- "New Persona" modal prompts for a display name and creates via IPC

Compose box:
- A #persona-select dropdown appears when 2+ personas exist
- doPost attaches postingIdHex to create_post / create_post_with_files
  when a non-default persona is selected

Tauri:
- create_post and create_post_with_files take an optional
  posting_id_hex; when present they route through create_post_as,
  otherwise through the default create_post_with_visibility
- PostDto gains asPersona: name of the authoring posting identity if
  the author matches any of our held personas
- is_me now recognises ALL our posting identities, not just the
  network key (both post_to_dto and post_to_dto_batch)

Feed:
- Per-post "(you) as <PersonaName>" label on own posts authored by a
  non-default persona
- Persona filter pill row above the feed (hidden for single-persona
  users); pills toggle between All and each persona; matches when
  post.author or post.recipients contains the selected posting id
- Applied after loadFeed initial render and after appendFeedPage so
  filter survives infinite-scroll

App.js:
- personasCache + loadPersonas() loaded on startup so compose picker
  is populated before the Feed tab mounts
- loadPersonas() also called when Settings tab opens

Backend was unchanged; only the UI and IPC surface expanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-21 23:09:06 -04:00
parent 7bdb2eb736
commit eea868b4cc
3 changed files with 245 additions and 15 deletions

View file

@ -473,7 +473,10 @@ function renderPost(post, index) {
const authorShort = post.author.substring(0, 12);
const authorName = post.authorName || authorShort;
const authorClass = post.isMe ? 'author-me' : '';
const meTag = post.isMe ? ' (you)' : '';
const personaTag = post.asPersona
? ` <span style="color:#7fdbca;font-size:0.7rem">as ${escapeHtml(post.asPersona)}</span>`
: '';
const meTag = post.isMe ? ` (you)${personaTag}` : '';
const timeStr = relativeTime(post.timestampMs);
const icon = generateIdenticon(post.author, 22);
const delay = Math.min(index * 0.04, 0.6);
@ -551,7 +554,8 @@ function renderPost(post, index) {
<div class="engagement-right"><button class="comment-toggle-btn" data-post-id="${post.id}">Comment${commentCount > 0 ? ` (${commentCount})` : ''}</button>${shareBtn}</div>
</div>`;
return `<div class="post" style="animation-delay: ${delay}s" data-post-id="${post.id}">
const recipientsData = (post.recipients || []).join(',');
return `<div class="post" style="animation-delay: ${delay}s" data-post-id="${post.id}" data-author="${post.author}" data-recipients="${recipientsData}">
<div class="post-meta">
<span class="post-author">${icon}${authorLink}${visBadge}</span>
<span class="post-time" title="${new Date(post.timestampMs).toLocaleString()}">${timeStr}</span>
@ -780,6 +784,7 @@ async function loadFeed(force) {
setupFeedScrollObserver();
}
setupFeedMediaObserver();
applyFeedPersonaFilter();
}
// Pre-fetch next page immediately
@ -833,6 +838,7 @@ async function appendFeedPage() {
}
feedList.appendChild(fragment);
applyFeedPersonaFilter();
setupFeedScrollObserver();
// Media observer auto-picks up new posts
@ -2572,6 +2578,14 @@ async function doPost() {
return;
}
}
// If a non-default persona is picked in compose, route through create_post_as
const personaSel = $('#persona-select');
if (personaSel && !personaSel.classList.contains('hidden') && personaSel.value) {
const defaultEntry = personasCache.find(p => p.isDefault);
if (!defaultEntry || personaSel.value !== defaultEntry.nodeId) {
params.postingIdHex = personaSel.value;
}
}
let result;
if (selectedFiles.length > 0) {
@ -3028,7 +3042,7 @@ document.querySelectorAll('.tab').forEach(tab => {
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
}
if (target === 'settings') { loadIdentities(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
});
});
});
@ -3525,6 +3539,173 @@ $('#import-identity-btn').addEventListener('click', () => {
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// --- Posting identities (personas) ---
// Cached list so the compose-box picker and settings list stay in sync.
let personasCache = [];
// 'all' (show everything) or a posting-id hex (filter to that persona).
let feedPersonaFilter = 'all';
async function loadPersonas() {
try {
personasCache = await invoke('list_posting_identities') || [];
} catch (e) {
personasCache = [];
console.error('list_posting_identities:', e);
}
renderPersonasList();
renderComposePersonaPicker();
renderFeedPersonaFilter();
}
function renderFeedPersonaFilter() {
const row = $('#persona-filter-row');
if (!row) return;
if (personasCache.length < 2) {
row.classList.add('hidden');
row.innerHTML = '';
return;
}
row.classList.remove('hidden');
const pills = [{ id: 'all', label: 'All' }].concat(
personasCache.map(p => ({
id: p.nodeId,
label: p.displayName || p.nodeId.substring(0, 8),
}))
);
row.innerHTML = pills.map(p => {
const active = feedPersonaFilter === p.id;
const bg = active ? '#7fdbca' : '#2a2a3e';
const fg = active ? '#0a0a1f' : '#aaa';
return `<button class="persona-pill" data-id="${p.id}"
style="padding:0.25rem 0.6rem;border-radius:999px;border:1px solid ${active ? '#7fdbca' : '#444'};background:${bg};color:${fg};font-size:0.7rem;white-space:nowrap;cursor:pointer">${escapeHtml(p.label)}</button>`;
}).join('');
row.querySelectorAll('.persona-pill').forEach(btn => {
btn.addEventListener('click', () => {
feedPersonaFilter = btn.dataset.id;
renderFeedPersonaFilter();
applyFeedPersonaFilter();
});
});
}
// Hide/show rendered post elements according to feedPersonaFilter.
function applyFeedPersonaFilter() {
const list = $('#feed-list');
if (!list) return;
const selected = feedPersonaFilter;
list.querySelectorAll('.post[data-author]').forEach(el => {
if (selected === 'all') { el.style.display = ''; return; }
const author = el.dataset.author || '';
const recipients = (el.dataset.recipients || '').split(',').filter(Boolean);
const matches = author === selected || recipients.includes(selected);
el.style.display = matches ? '' : 'none';
});
}
function renderPersonasList() {
const list = $('#personas-list');
if (!list) return;
if (personasCache.length === 0) {
list.innerHTML = '<p class="empty-hint" style="text-align:center">No posting personas yet.</p>';
return;
}
list.innerHTML = personasCache.map(p => {
const label = p.displayName || '(unnamed)';
const defaultTag = p.isDefault ? ' <span style="color:#7fdbca;font-size:0.65rem">(default)</span>' : '';
const setDefaultBtn = p.isDefault
? ''
: `<button class="btn btn-ghost btn-sm set-default-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem">Set default</button>`;
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>`;
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>`;
}).join('');
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('set_default_posting_identity', { nodeIdHex: btn.dataset.id });
toast('Default persona updated (takes effect next restart)');
loadPersonas();
} catch (e) { toast('Error: ' + e); btn.disabled = false; }
});
});
list.querySelectorAll('.delete-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this persona? All keys for it will be lost. Past posts under it remain on the network.')) return;
try {
await invoke('delete_posting_identity', { nodeIdHex: btn.dataset.id });
toast('Persona deleted');
loadPersonas();
} catch (e) { toast('Error: ' + e); }
});
});
}
function renderComposePersonaPicker() {
const sel = $('#persona-select');
if (!sel) return;
// Only show when there are 2+ personas; otherwise pointless.
if (personasCache.length < 2) {
sel.classList.add('hidden');
sel.innerHTML = '';
return;
}
sel.classList.remove('hidden');
const prior = sel.value;
const options = personasCache.map(p => {
const label = p.displayName || p.nodeId.substring(0, 8);
const tag = p.isDefault ? ' (default)' : '';
return `<option value="${p.nodeId}">${escapeHtml(label)}${tag}</option>`;
}).join('');
sel.innerHTML = options;
// Default to the stored default; preserve user selection across reloads.
const defaultEntry = personasCache.find(p => p.isDefault);
const defaultValue = defaultEntry ? defaultEntry.nodeId : personasCache[0].nodeId;
sel.value = (prior && personasCache.some(p => p.nodeId === prior)) ? prior : defaultValue;
}
$('#create-persona-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<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" />
<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>
</div>
</div>`;
document.body.appendChild(overlay);
const nameInput = overlay.querySelector('#new-persona-name');
nameInput.focus();
overlay.querySelector('#new-persona-create').addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
try {
await invoke('create_posting_identity', { displayName: name });
toast(`Persona created: ${name}`);
overlay.remove();
loadPersonas();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#new-persona-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') overlay.querySelector('#new-persona-create').click();
});
});
// Export wizard
$('#export-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
@ -3846,6 +4027,9 @@ async function init() {
await new Promise(r => setTimeout(r, 300));
}
}
// Load personas up front so the compose-box picker is ready before
// the user opens the Feed tab.
loadPersonas().catch(() => {});
const info = await loadNodeInfo();
// Show first-run chooser only if no profile AND only one identity (auto-created)
let isFirstRun = false;