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

@ -58,6 +58,10 @@ struct PostDto {
reaction_counts: Vec<ReactionCountDto>,
/// Number of comments on this post
comment_count: u64,
/// If the post is authored by one of our held posting identities, the
/// persona's display_name. None for posts authored by peers (or if the
/// local persona has no display name).
as_persona: Option<String>,
}
#[derive(Serialize)]
@ -208,7 +212,18 @@ async fn post_to_dto(
decrypted: Option<&str>,
node: &Node,
) -> PostDto {
let is_me = &post.author == &node.node_id;
// "is_me" now means: authored by ANY posting identity we hold, not just the
// network key. Covers the multi-persona case from 0.6.4+.
let (is_me, as_persona) = {
let s = node.storage.get().await;
match s.get_posting_identity(&post.author) {
Ok(Some(pi)) => {
let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
(true, name)
}
_ => (false, None),
}
};
let author_name = match node.resolve_display_name(&post.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
@ -280,6 +295,7 @@ async fn post_to_dto(
recipients,
reaction_counts,
comment_count,
as_persona,
}
}
@ -409,6 +425,7 @@ async fn create_post(
visibility: Option<String>,
circle_name: Option<String>,
recipient_hex: Option<String>,
posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
let node = get_node(&state).await;
let intent = match visibility.as_deref() {
@ -424,10 +441,13 @@ async fn create_post(
}
_ => VisibilityIntent::Public,
};
let (id, post, vis) = node
.create_post_with_visibility(content, intent, vec![])
.await
.map_err(|e| e.to_string())?;
let (id, post, vis) = match posting_id_hex {
Some(pid_hex) => {
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
node.create_post_as(&pid, content, intent, vec![]).await
}
None => node.create_post_with_visibility(content, intent, vec![]).await,
}.map_err(|e| e.to_string())?;
let decrypted = decrypt_just_created(&node, &post, &vis).await;
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}
@ -440,6 +460,7 @@ async fn create_post_with_files(
circle_name: Option<String>,
recipient_hex: Option<String>,
files: Vec<(String, String)>,
posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
let node = get_node(&state).await;
let intent = match visibility.as_deref() {
@ -467,10 +488,13 @@ async fn create_post_with_files(
})
.collect::<Result<Vec<_>, String>>()?;
let (id, post, vis) = node
.create_post_with_visibility(content, intent, attachment_data)
.await
.map_err(|e| e.to_string())?;
let (id, post, vis) = match posting_id_hex {
Some(pid_hex) => {
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
node.create_post_as(&pid, content, intent, attachment_data).await
}
None => node.create_post_with_visibility(content, intent, attachment_data).await,
}.map_err(|e| e.to_string())?;
let decrypted = decrypt_just_created(&node, &post, &vis).await;
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}
@ -819,20 +843,32 @@ async fn post_to_dto_batch(
let post_ids: Vec<itsgoin_core::types::PostId> = posts.iter().map(|(id, _, _, _)| *id).collect();
// Batch queries — 3 queries total instead of 4 × N
let (reaction_map, comment_map, intent_map) = {
let (reaction_map, comment_map, intent_map, posting_identities) = {
let storage = node.storage.get().await;
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
(reactions, comments, intents)
let identities = storage.list_posting_identities().unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
let persona_names: HashMap<itsgoin_core::types::NodeId, Option<String>> = posting_identities
.into_iter()
.map(|pi| {
let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
(pi.node_id, name)
})
.collect();
// Batch resolve display names
let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();
let mut dtos = Vec::with_capacity(posts.len());
for (id, post, vis, decrypted) in posts {
let is_me = post.author == node.node_id;
let (is_me, as_persona) = match persona_names.get(&post.author) {
Some(name) => (true, name.clone()),
None => (false, None),
};
let author_name = if let Some(cached) = name_cache.get(&post.author) {
cached.clone()
@ -897,6 +933,7 @@ async fn post_to_dto_batch(
recipients,
reaction_counts,
comment_count,
as_persona: as_persona.clone(),
});
}
dtos

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;

View file

@ -60,6 +60,7 @@
<!-- Feed tab -->
<section id="view-feed" class="view">
<div id="persona-filter-row" class="hidden" style="display:flex;gap:0.4rem;flex-wrap:wrap;padding:0.3rem 0.5rem;border-bottom:1px solid #222;overflow-x:auto"></div>
<div id="feed-list"></div>
</section>
@ -86,6 +87,7 @@
<div class="compose-left">
<span class="hint">Ctrl+Enter to post</span>
<div id="visibility-row">
<select id="persona-select" title="Post as" class="hidden"></select>
<select id="visibility-select">
<option value="public">Public</option>
<option value="friends">Friends</option>
@ -197,6 +199,13 @@
</div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.25rem">Personas</h3>
<p class="empty-hint" style="margin-bottom:0.5rem">Separate posting identities on this device. Peers see each persona as a distinct author.</p>
<div id="personas-list" style="margin-bottom:0.5rem;text-align:left"></div>
<button id="create-persona-btn" class="btn btn-ghost btn-sm">New Persona</button>
</div>
<div class="section-card" style="text-align:center">
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>