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:
parent
7bdb2eb736
commit
eea868b4cc
3 changed files with 245 additions and 15 deletions
190
frontend/app.js
190
frontend/app.js
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue