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