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
|
|
@ -58,6 +58,10 @@ struct PostDto {
|
||||||
reaction_counts: Vec<ReactionCountDto>,
|
reaction_counts: Vec<ReactionCountDto>,
|
||||||
/// Number of comments on this post
|
/// Number of comments on this post
|
||||||
comment_count: u64,
|
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)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -208,7 +212,18 @@ async fn post_to_dto(
|
||||||
decrypted: Option<&str>,
|
decrypted: Option<&str>,
|
||||||
node: &Node,
|
node: &Node,
|
||||||
) -> PostDto {
|
) -> 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 {
|
let author_name = match node.resolve_display_name(&post.author).await {
|
||||||
Ok((name, _, _)) if !name.is_empty() => Some(name),
|
Ok((name, _, _)) if !name.is_empty() => Some(name),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -280,6 +295,7 @@ async fn post_to_dto(
|
||||||
recipients,
|
recipients,
|
||||||
reaction_counts,
|
reaction_counts,
|
||||||
comment_count,
|
comment_count,
|
||||||
|
as_persona,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,6 +425,7 @@ async fn create_post(
|
||||||
visibility: Option<String>,
|
visibility: Option<String>,
|
||||||
circle_name: Option<String>,
|
circle_name: Option<String>,
|
||||||
recipient_hex: Option<String>,
|
recipient_hex: Option<String>,
|
||||||
|
posting_id_hex: Option<String>,
|
||||||
) -> Result<PostDto, String> {
|
) -> Result<PostDto, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
let intent = match visibility.as_deref() {
|
let intent = match visibility.as_deref() {
|
||||||
|
|
@ -424,10 +441,13 @@ async fn create_post(
|
||||||
}
|
}
|
||||||
_ => VisibilityIntent::Public,
|
_ => VisibilityIntent::Public,
|
||||||
};
|
};
|
||||||
let (id, post, vis) = node
|
let (id, post, vis) = match posting_id_hex {
|
||||||
.create_post_with_visibility(content, intent, vec![])
|
Some(pid_hex) => {
|
||||||
.await
|
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
|
||||||
.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;
|
let decrypted = decrypt_just_created(&node, &post, &vis).await;
|
||||||
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).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>,
|
circle_name: Option<String>,
|
||||||
recipient_hex: Option<String>,
|
recipient_hex: Option<String>,
|
||||||
files: Vec<(String, String)>,
|
files: Vec<(String, String)>,
|
||||||
|
posting_id_hex: Option<String>,
|
||||||
) -> Result<PostDto, String> {
|
) -> Result<PostDto, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
let intent = match visibility.as_deref() {
|
let intent = match visibility.as_deref() {
|
||||||
|
|
@ -467,10 +488,13 @@ async fn create_post_with_files(
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, String>>()?;
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
|
|
||||||
let (id, post, vis) = node
|
let (id, post, vis) = match posting_id_hex {
|
||||||
.create_post_with_visibility(content, intent, attachment_data)
|
Some(pid_hex) => {
|
||||||
.await
|
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
|
||||||
.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;
|
let decrypted = decrypt_just_created(&node, &post, &vis).await;
|
||||||
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).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();
|
let post_ids: Vec<itsgoin_core::types::PostId> = posts.iter().map(|(id, _, _, _)| *id).collect();
|
||||||
|
|
||||||
// Batch queries — 3 queries total instead of 4 × N
|
// 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 storage = node.storage.get().await;
|
||||||
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
|
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 comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
|
||||||
let intents = storage.get_post_intents_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
|
// Batch resolve display names
|
||||||
let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();
|
let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();
|
||||||
|
|
||||||
let mut dtos = Vec::with_capacity(posts.len());
|
let mut dtos = Vec::with_capacity(posts.len());
|
||||||
for (id, post, vis, decrypted) in posts {
|
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) {
|
let author_name = if let Some(cached) = name_cache.get(&post.author) {
|
||||||
cached.clone()
|
cached.clone()
|
||||||
|
|
@ -897,6 +933,7 @@ async fn post_to_dto_batch(
|
||||||
recipients,
|
recipients,
|
||||||
reaction_counts,
|
reaction_counts,
|
||||||
comment_count,
|
comment_count,
|
||||||
|
as_persona: as_persona.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dtos
|
dtos
|
||||||
|
|
|
||||||
190
frontend/app.js
190
frontend/app.js
|
|
@ -473,7 +473,10 @@ function renderPost(post, index) {
|
||||||
const authorShort = post.author.substring(0, 12);
|
const authorShort = post.author.substring(0, 12);
|
||||||
const authorName = post.authorName || authorShort;
|
const authorName = post.authorName || authorShort;
|
||||||
const authorClass = post.isMe ? 'author-me' : '';
|
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 timeStr = relativeTime(post.timestampMs);
|
||||||
const icon = generateIdenticon(post.author, 22);
|
const icon = generateIdenticon(post.author, 22);
|
||||||
const delay = Math.min(index * 0.04, 0.6);
|
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 class="engagement-right"><button class="comment-toggle-btn" data-post-id="${post.id}">Comment${commentCount > 0 ? ` (${commentCount})` : ''}</button>${shareBtn}</div>
|
||||||
</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">
|
<div class="post-meta">
|
||||||
<span class="post-author">${icon}${authorLink}${visBadge}</span>
|
<span class="post-author">${icon}${authorLink}${visBadge}</span>
|
||||||
<span class="post-time" title="${new Date(post.timestampMs).toLocaleString()}">${timeStr}</span>
|
<span class="post-time" title="${new Date(post.timestampMs).toLocaleString()}">${timeStr}</span>
|
||||||
|
|
@ -780,6 +784,7 @@ async function loadFeed(force) {
|
||||||
setupFeedScrollObserver();
|
setupFeedScrollObserver();
|
||||||
}
|
}
|
||||||
setupFeedMediaObserver();
|
setupFeedMediaObserver();
|
||||||
|
applyFeedPersonaFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fetch next page immediately
|
// Pre-fetch next page immediately
|
||||||
|
|
@ -833,6 +838,7 @@ async function appendFeedPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
feedList.appendChild(fragment);
|
feedList.appendChild(fragment);
|
||||||
|
applyFeedPersonaFilter();
|
||||||
setupFeedScrollObserver();
|
setupFeedScrollObserver();
|
||||||
// Media observer auto-picks up new posts
|
// Media observer auto-picks up new posts
|
||||||
|
|
||||||
|
|
@ -2572,6 +2578,14 @@ async function doPost() {
|
||||||
return;
|
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;
|
let result;
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
|
|
@ -3028,7 +3042,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
loadMessages(true); loadDmRecipientOptions();
|
loadMessages(true); loadDmRecipientOptions();
|
||||||
clearNotifications('msg-');
|
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(); });
|
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 wizard
|
||||||
$('#export-btn').addEventListener('click', () => {
|
$('#export-btn').addEventListener('click', () => {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
|
|
@ -3846,6 +4027,9 @@ async function init() {
|
||||||
await new Promise(r => setTimeout(r, 300));
|
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();
|
const info = await loadNodeInfo();
|
||||||
// Show first-run chooser only if no profile AND only one identity (auto-created)
|
// Show first-run chooser only if no profile AND only one identity (auto-created)
|
||||||
let isFirstRun = false;
|
let isFirstRun = false;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@
|
||||||
|
|
||||||
<!-- Feed tab -->
|
<!-- Feed tab -->
|
||||||
<section id="view-feed" class="view">
|
<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>
|
<div id="feed-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -86,6 +87,7 @@
|
||||||
<div class="compose-left">
|
<div class="compose-left">
|
||||||
<span class="hint">Ctrl+Enter to post</span>
|
<span class="hint">Ctrl+Enter to post</span>
|
||||||
<div id="visibility-row">
|
<div id="visibility-row">
|
||||||
|
<select id="persona-select" title="Post as" class="hidden"></select>
|
||||||
<select id="visibility-select">
|
<select id="visibility-select">
|
||||||
<option value="public">Public</option>
|
<option value="public">Public</option>
|
||||||
<option value="friends">Friends</option>
|
<option value="friends">Friends</option>
|
||||||
|
|
@ -197,6 +199,13 @@
|
||||||
</div>
|
</div>
|
||||||
</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 class="section-card" style="text-align:center">
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
|
<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>
|
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue