Three new Tauri commands surface Layer 4 to the frontend:
- rotate_v_me() -> { newEpoch }: generates next V_me epoch +
republishes bio. Old epoch retained in vouch_keys_own; existing
vouchees receive the new key on their next bio-scan.
- cascade_revoke_v_me_epoch(retired_epoch, reason_code) ->
{ postsRevoked }: bulk per-post revocation across every author
post that sealed slots under (self, retired_epoch). Useful as a
follow-up after rotate_v_me when the author wants to actively
cut off comment access on old posts.
- key_burn_post_slot(post_id_hex, slot_index, new_v_x_hex): the
leaked-V_me primitive. Re-seals one slot under a different V_x.
Frontend (Settings → Vouches):
- New "Rotate my vouch key" button + status pill below the Given /
Received lists.
- Confirmation prompt explains the grandfather-by-default semantics:
"old posts remain readable to anyone who held the old key — cascade-
revoke separately if you want to cut off old-content access."
- Wired once per settings-tab activation.
Cascade-revoke and key-burn surfaces aren't visualized yet (require
per-post selection UI); the Tauri commands are available for follow-up
UI work or scripting via the desktop dev console.
Workspace builds clean; 148 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end FoFClosed (Mode 1: encrypted body + FoF comments):
Node API:
- create_post_fof_closed(content) -> (PostId, Post, cek)
Builds gating, encrypts body via fof::encrypt_fof_body, base64s it
into post.content, stores with visibility=FoFClosed +
intent=Public, propagates via update_neighbor_manifests_as.
- read_fof_closed_body(post_id) -> Option<String>
Trial-unlocks via find_unlock_for_post, decrypts body, returns
plaintext. Returns None for non-FoFClosed or non-member readers.
Tauri commands:
- create_post_fof_closed, read_fof_closed_body. Registered in
generate_handler!.
Feed rendering:
- PostDto.visibility carries the new "fof-closed" string.
- renderPost(): FoFClosed posts render with a locked placeholder
(data-fof-closed-pending=post_id span). Visual badge added.
- unlockFoFClosedPlaceholders(rootEl): post-render async pass that
scans for placeholder spans and dispatches read_fof_closed_body
for each. Fills in body for FoF readers; falls back to a
"not in this FoF set" notice otherwise.
- Wired into feed-list and my-posts-list render paths.
Compose:
- "Body+Comments: FoF only (Mode 1)" option in comment-perm-select.
Selected → dispatches to create_post_fof_closed.
CLI feed renderer + Tauri feed-DTO match arms updated to handle
FoFClosed.
New end-to-end test brings total to 146:
- fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with
Alice's V_me in his keyring) unlocks + decrypts; Carol (no
matching V_x) cannot unlock and sees only ciphertext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new Tauri commands surface Layer 2 to the frontend:
- create_post_with_fof_comments(content) → { postId }
Builds the FoF gating block from the default persona's keyring and
publishes a Mode 2 post (public body, FoF-gated comments).
- comment_on_fof_post(post_id_hex, body)
Backwards-compat wrapper: comment_on_post now auto-detects FoF
gating on the parent post and dispatches internally. Existing
comment-send UI works unchanged.
- revoke_fof_commenter(post_id_hex, pub_x_index, reason_code)
Wraps the Node helper. UI for per-comment revocation can hang off
this without further Node changes.
Node::comment_on_post now branches on parent post fof_gating presence
— this is the central routing point so neither the frontend nor
existing comment-send code needs to know about FoF specifics. FoF
posts get FoF comments automatically; non-FoF posts get the legacy
path. Same Tauri command, same frontend handler.
Frontend:
- Compose: comment-perm-select gains a "Friends of Friends" option.
When picked, compose dispatches to create_post_with_fof_comments
instead of the standard create_post. Standard set_comment_policy
diff fires after so receivers' four-check accept rule activates.
- Attachments + non-default-persona FoF posts are out-of-v1; compose
reports + aborts rather than silently producing a non-FoF post.
Layer 2 backend + minimal-viable UI complete. Granular per-comment
revoke UI and access-grant UI deferred — Node + Tauri primitives
exist; the surfaces can be added without further crypto work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node helpers (crates/core/src/node.rs):
- vouch_for_peer(target): derives target X25519 pub from NodeId,
inserts into own_vouch_targets, republishes bio post so the new
VouchGrantBatch propagates to the receiver via CDN.
- revoke_vouch_and_rotate(target): per Scott's design, revocation IS
the rotation primitive. Marks target current=0, generates new V_me
epoch in vouch_keys_own (prior retained, Layer 4 receiver-chain),
republishes bio. Revoked persona retains old V_me → grandfathered
access to old content; locked out of new content sealed under V_new.
- list_vouches_given / list_vouches_received: enriched with display
names via resolve_display_name.
Tauri commands (crates/tauri-app/src/lib.rs):
- vouch_for_peer, revoke_vouch_for_peer (single-action commands)
- list_vouches_given, list_vouches_received (DTOs with camelCase)
- All registered in the generate_handler! list.
Frontend (frontend/index.html + app.js):
- New "Vouches" section in Settings tab with side-by-side Given /
Received lists. Per-row Revoke button on Given entries with confirm
prompt explaining the rotation semantics.
- Bio modal gains Vouch / Revoke Vouch button next to Follow/Unfollow.
State derived from list_vouches_given on modal open.
- loadVouches wired into the settings-tab activation handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
per-author feed filter, ignore primitive
The old People tab was built on network-layer presence (`is_online`,
`last_seen` from the mesh), which was lost when v0.6.1 anonymized the
network id from the posting id. Every named follow is authored under a
posting id that doesn't appear in the connection-layer tables; the
"Online" section listed nobody useful and Discover depended on the
same broken signal.
Replaced with signals derived from signed content:
- Following is sorted by most-recent-post timestamp (the real meaning
of "activity" in a post-anonymization world).
- Discover lists named peers we've received signed profile posts from
(via Phase 2d), filtered by follows / ignores / self.
- Click-a-name surfaces a bio modal with View Posts / Follow / Message
/ Ignore actions.
- Author-scoped feed filter (`View Posts` on any person) renders a
"Showing posts from X" banner with a Clear button.
- Ignore is a new local-only primitive; ignored peers' posts and
profiles are excluded everywhere and the ignored list is editable
in Settings.
Core changes:
- New `ignored_peers(node_id, ignored_at)` table + storage helpers
(`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`,
`is_ignored_peer`). Schema created fresh; no migration since the
table is purely additive and empty on prior installs.
- All 6 feed-query sites now also exclude `author IN ignored_peers`.
- New `Storage::last_activity_for_authors(&[NodeId])` — one batched
query returning max post timestamp per author, excluding non-feed
intents (Control / Profile / Announcement / GroupKeyDistribute).
- New `Storage::list_discoverable_profiles(&self_id)` — named profile
rows where node_id is not self, not in follows, not in ignored, and
`public_visible = 1`. Sorted by profile `updated_at` DESC.
- New `Storage::delete_setting(key)` — missing counterpart to
set/get.
- Node wrappers: `last_activity_for_follows`, `ignore_peer`
(also drops any follow + social route for the ignored peer),
`unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`.
- `list_follows` Tauri command now sources `last_activity_ms` from
the posts-driven batched query rather than the network peer record.
Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`,
`list_ignored_peers`.
Frontend:
- Following list: see-new-activity button pattern (staged data +
explicit user click to rearrange, so the list doesn't reorder under
a tap mid-scroll). Periodic people-tab polling stages + lights up
the button; clicking it re-renders.
- Discover: rewrites the old peer-table-based list to a profile-post
feed. Each card shows name + bio + profile-update age, plus Follow
/ Posts / Ignore actions.
- Bio modal: reuses the existing generic popover. Loads display name
+ bio via `resolve_display`, shows follow state, offers View Posts /
Follow-or-Unfollow / Message / Ignore-or-Unignore.
- Author filter: banner renders at the top of the feed when active;
clear button restores full feed. Filter state is a single `authorFilterNodeId`
field consumed by `filterFeedPosts`.
- Settings → Ignored section lists ignored peers with unignore buttons.
124 / 124 core tests pass.
Two bugs the v0.6.2 Discover story was going to expose:
1) Many named personas don't have a profile post yet, so the planned
profile-post-driven Discover listing would show them as headless.
Existing personas predate the Phase 2d profile-post primitive, and
imported personas arrive with a display_name but no matching post.
2) Fresh install always generates a blank disposable persona before
the user can pick fresh-vs-import. If the user picks import, that
blank persona lingers forever — visible in the Personas list, a
potential default, confusing.
Fixes:
Profile-post backfill (node.rs):
- New `Node::backfill_profile_posts_for_named_personas`: scans posting
identities, skips unnamed or already-covered ones, and emits a
signed profile post at the persona's own `created_at` timestamp. Run
once from `Node::open_with_bind` after migrations. Idempotent via
the new `Storage::has_profile_post_by_author` check. Chronology is
preserved (post timestamp matches persona creation), so a later
genuine profile update the user authors always wins the `> old_ts`
monotonicity check on receivers.
- `Node::create_posting_identity(name)`: if `name` is non-empty, emits
the profile post inline so new named personas are Discover-able the
moment they're created. Uses the new `publish_profile_post_as`
helper, which signs with the persona's own secret (not the default
posting secret) and propagates via `update_neighbor_manifests_as`.
First-run persona marker + targeted prune (node.rs + storage.rs + import):
- `Node::open_with_bind`'s auto-gen block now also writes
`first_run_auto_persona_id = <hex>` into the settings kv. This marks
the specific disposable persona from the fresh-install flow; later
prune logic uses this id, not a "any empty persona" rule, so manual
empty personas are never touched.
- `Node::try_prune_first_run_auto_persona`: deletes the marked id iff
all four gates pass — still exists, no display_name, no authored
posts, no authored reactions/comments, and it's no longer the
current default. Any one failure → clear the marker and keep the
persona. New storage helpers `has_any_post_by_author`,
`has_any_engagement_by_author` back the check.
- `set_profile` clears the marker when a non-empty name lands on the
marked persona (user claimed it).
- `storage.delete_setting(key)` — new one-line helper.
- `import_as_personas_cmd` in tauri-app calls the prune after import
completes; the cmd's return message reports "(cleared blank starter
persona)" when it fires.
- New `get_first_run_auto_persona_id` Tauri command so the frontend
can filter the blank persona out of the Personas list while it
still exists.
- Frontend `loadPersonas` filters the marker id out of
`personasCache` before rendering.
Tests: 124 / 124 core tests pass.
New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.
Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
stores the latest-per-category/channel in the settings kv, keyed
e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
version > CARGO_PKG_VERSION on the user's selected channel, a banner
appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
button + current status line.
Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
`get_feed*` / `list_posts*` query sites updated to also exclude the
new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
`apply_announcement_if_applicable`, `latest_release`,
`build_announcement_post`, and a `StoredAnnouncement` envelope saved
to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
id equals the anchor constant — accidental use on client installs
fails loud.
Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
alongside Control and Profile. Same pattern; same guarantees.
CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
--ann-channel stable --ann-version X --ann-title ... --ann-body ...
--ann-url https://itsgoin.com/download.html` — builds + stores +
propagates the signed post, exits.
deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
(after binary swap, before start). The DB is free during that gap,
so the one-shot can write without conflicting with the running
daemon. The restarted daemon loads all storage on boot and serves
the new announcement to pulling peers.
Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
— returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
refuses non-http(s) URLs. Android needs the opener plugin — TODO.
Frontend:
- Upgrade banner on the welcome screen, populated by
`loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
+ current status line.
Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
Removes the last persona-signed direct push on the wire. Group/circle
seeds no longer travel via the 0xA0 `GroupKeyDistribute` uni-stream from
admin to member. Instead the admin publishes an encrypted post containing
the seed + metadata; each member is a recipient; the post propagates via
the normal CDN. Members decrypt with their posting secret to recover the
seed.
Eliminates the wire-level coordination signal between an admin endpoint
and each member endpoint when a group is created, a member is added, or
a key is rotated.
Core pieces:
- New `VisibilityIntent::GroupKeyDistribute` variant.
- New `types::GroupKeyDistributionContent` — JSON payload inside the
encrypted post: group_id, circle_name, epoch, group_public_key, admin,
canonical_root_post_id, group_seed.
- New `group_key_distribution` module:
- `build_distribution_post(admin, admin_secret, record, group_seed, members)`
returns `(PostId, Post, PostVisibility::Encrypted)` — wraps the CEK
per member using standard `crypto::encrypt_post`.
- `try_apply_distribution_post(s, post, visibility, our_personas)`
iterates every posting identity's secret trying to decrypt; on
success stores `group_key` + `group_seed` and returns true.
- `process_pending(s, our_personas)` scans stored
GroupKeyDistribute-intent posts and applies any we can decrypt.
Node API:
- `add_to_circle`: builds a distribution post wrapping the current seed
to just the new member, stores with intent=GroupKeyDistribute, and
propagates via `update_neighbor_manifests_as` (no direct push).
- `create_group_key_inner`: at group creation, after wrapping keys for
every non-self member, builds one distribution post addressed to all
of them and propagates through the CDN.
- `rotate_group_key`: same pattern at epoch rotation.
- New `Node::process_group_key_distributions` — scans and applies.
`sync_all` now calls it automatically so seeds take effect right after
a pull cycle.
Removals (wire-breaking; v0.6.2 already forked):
- MessageType 0xA0 (`GroupKeyDistribute`), its payload struct, the
handler in connection.rs, and `Network::push_group_key` all deleted.
ConnectionManager's `secret_seed` (network secret) is no longer used for
group-key unwrapping — that shifted to posting secrets in the apply
pass, matching the v0.6.1+ identity split where group keys are wrapped
to posting NodeIds.
Tests: new `member_decrypts_and_applies` covers a recipient decrypting +
storing the seed and a non-recipient failing to apply. Workspace
compiles clean; 118 / 118 core tests pass on a stable run (pre-existing
flaky `relay_cooldown` test with a 1ms timing window is unrelated).
v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.
Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
functions targeting audience are deleted; the existing handlers stay
(both already dead code on the send side).
Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
`push_disconnect_to_audience`, `push_address_update_to_audience`,
`send_audience_request`, `send_audience_response`, `send_to_audience` —
all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
`remove_audience`, `list_audience_members`, `list_audience` removed from
Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
`AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
`remove_audience`, `row_to_audience_record`, `audience_crud` test, the
`audience` CREATE TABLE, and the audience-dependent social route rebuild
branch all removed. Upgraded DBs retain the orphan `audience` table;
nothing touches it.
Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
author's public follows (`list_public_follows`) rather than a separate
audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
unfollow removes the social route entirely.
UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
`request_audience`, `approve_audience`, `remove_audience` commands
removed from invoke_handler. Frontend: audience panel and audience/mutual
badges removed; compose permission dropdown shows "Followers" instead of
"Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.
Tests: 111 / 111 core tests pass.
Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
AndroidFs trait must be in scope for show_open_file_dialog / read
method resolution — AndroidFsExt alone isn't enough. Matches the
existing share_file pattern that does use both traits.
Caught on the v0.6.1 Android build; previous cargo check only
validated the desktop target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reset All Data:
- Sentinel now written at the app-level data_dir instead of the
active identity's subdir. On Android the subdir path was never
checked at startup, so reset silently did nothing.
- On detection, wipe EVERYTHING under the app data_dir: identity.key,
itsgoin.db + WAL + SHM, blobs, all identity subdirs. Next launch
is truly fresh — new network key, new posting key, no prior data.
First-run name:
- Display name is optional. Blank submits as anonymous.
- First-run modal + profile overlay placeholder updated to say
"Display name (optional)".
Android file picker:
- pick_file on Android now uses tauri-plugin-android-fs'
show_open_file_dialog (Storage Access Framework OPEN_DOCUMENT).
Read the picked URI's bytes, stage them in the app's private cache
as a timestamped file, return the staged path so existing
import_* code can read it as a regular filesystem path.
- Zip filter passes application/zip + application/octet-stream (some
file providers report the latter for .zip).
Android auto-backup off:
- AndroidManifest: allowBackup="false", fullBackupContent="false",
dataExtractionRules pointing at new data_extraction_rules.xml
- New data_extraction_rules.xml excludes all domains from both
cloud-backup and device-transfer. Prior default (allowBackup=true)
silently replicated identity.key to Google Drive for any user with
cloud backup on — which effectively published the root secret to
a third party without asking. Users who want off-device backup use
Settings -> Export (explicit zip they control).
Import as personas:
- New import_as_personas function in core/import.rs + new
import_as_personas_cmd Tauri IPC.
- Reads identity.key from the bundle and adds it to posting_identities
as a persona. Also reads posting_identities.json (v0.6+ bundles)
and adds each entry. Dedupes by node_id.
- Posts stay AS-AUTHORED — original post_id, original author,
original signatures, original wrapped_key recipients. No
re-encryption. Content encrypted to any of the imported keys
becomes decryptable because we now hold the secrets.
- Blobs, follows, profiles copied across.
- If current device has <=1 posting identity (the fresh-install one)
and the bundle brings more, auto-switch the default to the first
imported persona. Covers first-run-then-import flow cleanly.
Import wizard UI:
- New default option: "Restore as personas" — posts keep original
authors; source's keys become personas you can post as.
- Old "Merge with decryption key" retained as "Consolidate under
current default persona (requires source key)" for the case where
a user intentionally abandons a persona.
- "Public posts only" and "Add as separate identity" retained.
deploy.sh made executable (chmod +x tracked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Users can now hold multiple posting identities on one device and
publish content under any of them. Each persona has its own ed25519
key; peers see them as distinct authors with no link back to the
device's network identity.
Node methods:
- list_posting_identities() -> Vec<PostingIdentity>
- create_posting_identity(display_name) — generates a fresh ed25519
key, persists, auto-follows self
- delete_posting_identity(node_id) — refuses to delete the default
- set_default_posting_identity(node_id) — validates identity exists;
Node's cached default_posting_id/secret picks up on next restart
- create_post_as(posting_id, content, intent, attachments) — routes
through a shared create_post_inner that takes posting_id +
posting_secret as parameters
Post creation pipeline:
- create_post_with_visibility now delegates to create_post_inner
using default_posting_id/secret
- create_post_inner threads posting_id / posting_secret through
every content-signing, encryption, manifest, blob-header, and
CDN-manifest step — the persona is fully honored end to end
- update_neighbor_manifests now takes a posting_id param too, so
posts from persona X only update neighbor manifests for X's own
prior posts
Tauri IPC:
- list_posting_identities / create_posting_identity /
delete_posting_identity / set_default_posting_identity
- create_post_as with posting_id_hex + the same visibility params
as create_post
CLI:
- personas / create-persona <name> / delete-persona <id>
- post-as <posting_id> <text>
Smoke-tested two-persona scenario:
- A creates "Work" persona; posts from default and Work
- B follows both; pulls from A; gets all three posts
- Authors are AB84BA... (Work) and 7CD949... (default) — distinct
on the wire
Frontend UX (Settings > Personas, compose picker, filter pills,
merged feed labels) is scoped as a separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Store the external address reported by peers via your_observed_addr in
initial exchange. Display it in Our Info panel with NAT classification.
Replaces reliance on iroh's pkarr/STUN for external address discovery
while keeping clear_address_lookup() (no dns.iroh.link publishing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When anchor reports duplicate identity, sync pauses and a red banner
shows with a "Continue Anyway" button. Clicking it clears the flag and
starts all sync tasks. Handles false positives from network changes
(WiFi/cellular/VPN switches) without requiring complex staleness checks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Feed pagination:
- Cursor-based pagination: get_feed_page/get_all_posts_page (20 posts/page)
- Batched engagement queries (3 bulk SQL queries instead of 4 per post)
- IntersectionObserver for infinite scroll (sentinel at midpoint)
- Viewport-based media loading (blobs only load when post enters view)
- Pre-fetch next page immediately after current page renders
Duplicate identity detection:
- Anchor detects when a NodeId is already mesh-connected during initial
exchange and sets duplicate_active flag in response
- Client skips sync tasks when duplicate detected
- Frontend shows red warning banner
Privacy:
- Fixed pkarr leak: clear_address_lookup() removes default dns.iroh.link
publishing. Only mDNS (local network) discovery enabled.
Android:
- SAF integration via tauri-plugin-android-fs: exports open native "Save As"
dialog so users can save to Downloads/Drive/etc.
- Download/export paths use app data dir on Android (writable)
- File picker gated behind desktop cfg (blocking_pick not on Android)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Node::open_with_bind no longer runs bootstrap (anchor connect, NAT
probe, referrals). New run_bootstrap() method called from background
task after UI is live.
- Background tasks (pull cycle, diff cycle, etc.) start after bootstrap
completes, not during block_on.
- Feed no longer pre-loaded during welcome screen readiness check.
Ready button enables immediately after get_node_info succeeds.
- Feed loads on tab switch, not during startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- First-run: show Start Fresh / Import chooser only when single auto-created
identity with no profile (not on every boot without a display name).
- Identity switch: shut down old node's endpoint before starting new one.
Fixes lockup after multiple switches (zombie background tasks).
- File picker: native Browse buttons on export (folder) and import (ZIP file)
via tauri-plugin-dialog.
- Export path: resolve relative paths against home dir (was using process cwd).
- Lightbox close: only close on overlay/image click, not inner form content.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Network Diagnostics: "Our Info" button shows addresses with NAT status,
device role, UPnP, HTTP capability. Addresses stacked for mobile.
- Hole punch race: re-check for existing connection before and after relay
introduction to avoid wasting minutes on redundant punch attempts.
- Relay introduction now carries requester/target NAT mapping+filtering
so hole punch strategy uses fresh profiles instead of stale stored ones.
Critical for phones that switch between WiFi/cellular/VPN.
- STUN fix: filter DNS results to IPv4 (was resolving to IPv6 first on
dual-stack, causing silent send failure and "NAT unknown").
- Welcome screen: Ready button with loading bar for instant feed access.
- LAN addresses show just "LAN" (no misleading punchability label).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge-with-key: decrypt exported posts using original identity seed, re-create
under current identity with prior_author in BlobHeader for provenance tracking.
Download page restructured with stable (v0.4.4) + beta (v0.5.0-beta) sections.
Version bumped across all crates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Export (export.rs): ZIP archive with auto-chunking at 4GB. Four scopes:
identity only, posts only, posts+identity, everything (posts+key+follows+
profiles+settings). Includes blobs. Manifest JSON tracks metadata.
Import (import.rs): Read ZIP summary without importing (preview).
Import public posts into current identity with new PostIds + original
timestamps. Import as new identity (creates identity subdir from key).
Uses spawn_blocking for ZIP I/O to avoid Send issues with ZipArchive.
Tauri IPC: export_data, import_summary, import_public_posts,
import_as_new_identity commands. IdentityManager.base_dir() getter.
Frontend: Export wizard lightbox with scope radio buttons + output dir.
Import wizard with ZIP path, preview summary, action selection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New Tauri commands: list_identities, create_identity, switch_identity,
delete_identity, import_identity_key, get_active_identity.
Settings UI: Identities section with list (active indicator, switch/delete
buttons), Create New Identity lightbox, Import Identity Key lightbox.
Export/Import buttons as placeholders for Phase 2/3.
Identity switch does hot-swap: tears down old Node, starts new one with
all background tasks, swaps AppNode RwLock. Frontend reloads after switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New identity.rs: IdentityManager manages per-identity data directories
under identities/{node_id_hex}/. Supports create, list, switch, delete,
import from key. Legacy flat layout auto-migrates on first launch.
Tauri AppState refactored from Arc<Node> to AppNode (Arc<RwLock<Arc<Node>>>)
+ AppIdentity (Arc<Mutex<IdentityManager>>). All 76 IPC commands updated
to get_node(&state).await pattern. Enables hot-swap identity switching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminate all conn_mgr lock holds during network I/O across 14 actor commands
and bi-stream handlers. PostFetch, TcpPunch, PullFromPeer, FetchEngagement,
ResolveAddress, AnchorProbe use brief locks for data gathering only. WormLookup,
ContentSearch, WormQuery use connection snapshots for lock-free cascade fan-out.
RelayIntroduce extracts forwarding data under brief lock, does I/O outside.
BlobRequest, PostFetchRequest, ManifestRefresh use Arc clones instead of conn_mgr
lock. ConnectionActor hoists shared Arcs (storage, blob_store, endpoint) for
lock-free access. ResolveAddress adds 5s per-query timeout (was unbounded).
Initial exchange failure now aborts mesh upgrade (was silently continuing with
broken connection). connect_to_peer/connect_to_anchor use consistent 15s timeout.
Rebalance connects outside the lock via pending_connects pattern.
StoragePool: 8 concurrent SQLite connections in WAL mode replace single
Mutex<Storage>. Reads run fully parallel; writes serialize at SQLite level only.
PRAGMA busy_timeout=5000 for graceful write contention.
Mobile bottom nav bar (<=768px) with icon tabs. Text sizes: XS/S/M/L/XL
(75%/100%/125%/150%/200%), default M. localStorage persistence for instant
restore. Toast repositioned above mobile nav.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Welcome screen with staggered counters while backend bootstraps. Header status
ticker for new posts/messages/reactions/comments/connection changes. Notification
fallback chain (Tauri plugin → Web API → notify-rust). Responsive text scaling
(Small/Normal/Large, persisted). Diagnostics moved to popover with on-demand
connections. Share details lightbox with QR code. Connect string prefers external
address. Stale N1 fix (disconnected routes excluded). Replication handler actively
fetches posts+blobs from requester. Hole punch registers remote address for relay.
Replication semaphore (3 concurrent). Peer labels show truncated node ID.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Encrypted slots in BlobHeader:
- Private posts get noise-prefilled receipt slots (64B, 1 per participant)
and comment slots (256B, ceil(participants/3), expandable)
- Slot key derived from post CEK via BLAKE3 — only participants can read
- CDN relays propagate opaque encrypted bytes without decryption
- 3 new BlobHeaderDiffOps: WriteReceiptSlot, WriteCommentSlot, AddCommentSlots
Receipt system:
- States: empty(0), delivered(1), seen(2), reacted(3)
- Slot index = position in sorted participant NodeId list
- Author can pre-feed emoji reaction at creation time
- 6 new crypto tests for slot encrypt/decrypt/derivation
Node methods:
- write_receipt_slot, write_comment_slot with upstream+downstream propagation
- read_receipt_slots, read_comment_slots with CEK-based decryption
- get_post_cek_and_participants helper for both Encrypted and GroupEncrypted
IPC: write_message_receipt, write_message_comment, get_message_receipts,
get_message_comments
Frontend:
- DM chat bubbles show delivery indicators (check → double → blue → emoji)
- Opening conversation auto-marks incoming messages as seen
- React button on messages with emoji prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment edit & delete:
- EditComment/DeleteComment BlobHeaderDiffOps with upstream+downstream propagation
- Trust-based: comment author can edit/delete, post author can delete
- Storage: edit_comment(), delete_comment() methods
- Frontend: inline edit (Enter/Escape), delete with confirm
Native notifications:
- tauri-plugin-notification for system notifications on all platforms
- Triggers for messages, new posts, reactions, and comments
- notif_reacts setting added, button-group toggles replace dropdowns
- _notifReady flag prevents startup spam
Protocol hardening:
- BlobHeaderDiffOp::Unknown variant with #[serde(other)] for forward compatibility
- Old nodes silently skip unknown ops instead of crashing
UI fixes:
- Self removed from Following list
- Offline follows in lightbox popup (auto-show if no one online)
- Sent DMs filtered from My Posts
- Comment threading scoped to closest .post (fixes duplicate ID issue)
- Select dropdown text legible in WebKitGTK (black on white options)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>