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.
The Phase 2f `canonical_root_post_id` column was added to both the
CREATE TABLE block (for fresh DBs) and a conditional ALTER TABLE (for
upgrading DBs). The matching CREATE INDEX, however, was inlined in the
CREATE TABLE block — which runs BEFORE the ALTER. On an upgrading DB
with the v0.6.1 schema, CREATE TABLE IF NOT EXISTS is a no-op against
the existing (old) table, so the table still lacks the column when the
index statement runs and SQLite bails with "Error code 1: SQL error or
missing database" at offset 74.
Caught on the v0.6.2 anchor deploy — the anchor died right after start
because its v0.6.1 DB couldn't apply migrations.
Fix: remove the index from the CREATE TABLE block; run an unconditional
`CREATE INDEX IF NOT EXISTS idx_group_keys_root ...` in the migration
section, after the conditional ALTER has added the column. Idempotent
in both paths — fresh DB (column from CREATE TABLE) and upgrading DB
(column from ALTER).
121 / 121 core tests pass.
Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.
Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
When `Some`, the record represents a group rooted at that public post;
when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
index `idx_group_keys_root` on it. Migration added for upgraded DBs;
the CREATE TABLE statement carries the column so fresh DBs match.
Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
field. v0.6.1 peers will deserialize it as absent and continue treating
the record as a circle. All three sender sites (new-circle distribute,
add-member distribute, epoch-rotation distribute) pass the field through.
Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
and read the new column. Added a shared `row_to_group_key` helper so the
two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
used by posting / retrieval flows.
Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
backing circle named `group:<6-byte-hex-of-root>`, initializes the
group key with `canonical_root_post_id` set, and invites each initial
member (reusing `add_to_circle`'s wrap+distribute path so members get
the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
the group seed can call this. Looks up the group by root, routes
through `create_post_with_visibility(Circle(name))` (which already
chooses `GroupEncrypted` when the seed is present), then stores a
`ThreadMeta` row linking the new post back to the canonical root so
retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
via the ThreadMeta parent index. Callers decrypt normally; members see
full content, non-members see encrypted blobs.
Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
`create_group_key_inner(circle_name, canonical_root)` helper, keeping
one place where the record is constructed and the seed is persisted.
Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
circles already used. The "admin-only post" restriction on circles was
a UX choice, not a cryptographic limit — groups expose the many-way
path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
comment threads; groups reuse it so the query pattern
("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
and exercise-able via Tauri/CLI.
Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
A comment can now reference a separate Post that carries the full body
(long text, attachments, rich formatting). The inline comment's `content`
becomes a short preview string; the referenced post propagates through the
normal CDN and readers fetch it lazily when rendering the expanded view.
Type change:
- `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]).
When None, `content` is the full comment text (v0.6.1 shape — unchanged on
the wire). When Some, `content` is the preview.
Signature binding:
- `crypto::sign_comment` / `verify_comment_signature` now take
`ref_post_id: Option<&PostId>`. The signed digest appends
`b"ref:" || ref_post_id` only when a ref is present, so plain comments
produce the same digest as the v0.6.1 scheme and remain verifiable
without a migration. When a ref is present the signature binds it, so a
peer can't strip or swap the reference without re-signing.
Storage:
- `comments` table gets a `ref_post_id BLOB` column (nullable). Added to
both the CREATE TABLE statement and a conditional ALTER TABLE migration
so upgraded DBs pick it up automatically.
- `store_comment`, `get_comments`, `get_comments_with_tombstones` read and
write the column.
Node API:
- `comment_on_post` stays as the plain-comment entry point (calls the
inner helper with `ref_post_id = None`).
- New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich
comments. Both share a single inner helper that signs, stores, and
propagates via BlobHeaderDiff.
connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()`
to the signature verify so forged or rewritten refs are rejected.
Tests: new crypto test asserting the signature binds ref_post_id (strip /
swap / drop all fail); new storage test asserting ref_post_id roundtrips
through live + tombstone reads. 116 / 116 core tests pass.
Client-side UX (pulling the ref post on expand, composing rich comments)
is frontend work that will land with the next UI iteration.
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.
Fresh installs now generate two independent ed25519 keys — one as the
network (QUIC) identity in identity.key, and a SEPARATE one as the
default posting identity in posting_identities. They share nothing.
v0.6.0 upgraders: if the default posting key equals the network key
(the state Phase 4's migration left us in), rotate identity.key to a
fresh random value. The old key stays in posting_identities as the
default persona — peers keep seeing the same author on our posts; only
the QUIC NodeId changes. A one-shot reconnect-churn on upgrade, then
back to normal.
Storage:
- Drop seed_posting_identity_from_network (v0.6.0-specific helper)
- Add count_posting_identities()
Node::open_with_bind:
- Load identity.key (network secret — network-only from now on)
- Ensure posting_identities has at least one entry; if empty, generate
an INDEPENDENT random posting key as the default
- Detect default-posting-key == network-key collision and rotate
identity.key, logging the migration
- default_posting_id / default_posting_secret resolved from storage
Decrypt:
- decrypt_posts now takes &[PostingIdentity] and tries each held
persona as a recipient candidate. Past DMs to any persona on this
device (including ones added via Import as personas) decrypt
correctly. Callers pre-load list_posting_identities() alongside
group_seeds.
- decrypt_just_created looks up the author's specific posting identity
rather than assuming the default.
Profile broadcasts (wire-level privacy):
- Profile stays keyed by network NodeId — the field is load-bearing
for N1/N2/N3 social routing (anchors/recent_peers/preferred_peers
feed build_preferred_tree_for and peer-anchor reachability lookup).
- But push_profile and InitialExchange now STRIP display_name, bio,
and avatar_cid before sending, via new
PublicProfile::sanitized_for_network_broadcast(). A name attached to
the network id would correlate the QUIC endpoint to a human. Until
v0.6.2 introduces persona-signed profile posts, peers display
authors as hex.
Auto-follow only the default posting id (network id is never an
author, following it would be dead weight).
All 111 core tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouple the signing identity from the network identity. This phase
ships the plumbing only — every device still has exactly one posting
identity, copied from the network key on first 0.6.3 launch so all
existing signed content keeps verifying. Phase 5 builds the
multi-persona UX on top.
Types:
- New PostingIdentity struct: { node_id, secret_seed, display_name,
created_at }
Storage:
- New posting_identities(node_id, secret_seed, display_name,
created_at) table
- Methods: upsert / get / list / delete posting identities;
get/set default posting id (stored in settings)
- seed_posting_identity_from_network: idempotent migration inserts
the network key as the single posting identity and sets it default
on first 0.6.3 launch
Node:
- default_posting_id + default_posting_secret fields populated on
startup via the migration
- All content signing / encryption / key wrapping now uses
default_posting_secret; the old Node.secret_seed field is gone
(iroh holds the network secret internally)
- author field on all locally-created content is now
default_posting_id (equal to node_id for upgraders until Phase 5
introduces separate personas)
- Auto-follow-self covers both network_id and default_posting_id
(same in 0.6.3, may diverge in 0.6.4+)
Export/import:
- Bundle now includes posting_identities.json in
IdentityOnly / PostsWithIdentity / Everything scopes
- restore_posting_identities(zip, storage) reads and upserts on
import
Smoke-tested:
- Fresh 0.6.3 install: posting_identities seeded from network key;
default set; new post's author = default_posting_id = network_id
- Two-node pull sync: B pulls A's post, signature verifies across
the wire
- 111 core tests pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A non-follower can now receive DMs addressed to them via a normal pull
cycle, with no distinguishable "searching for DMs" traffic pattern —
the pull query is a uniform list of NodeIds that the server matches
against both authors and wrapped-key recipients.
Schema (migrations on first 0.6.2 launch):
- New post_recipients(post_id, recipient) index table with index on
recipient column
- Seed migration scans existing encrypted posts, extracts recipients
and group members from visibility JSON, populates the index
Write path:
- store_post_with_visibility / store_post_with_intent populate
post_recipients on successful insert
- update_post_visibility rebuilds the index for the updated post
- apply_delete cascade-removes post_recipients entries
Server pull handler (should_send_post):
- Renamed semantic: requester_follows → query_list. Contains every
NodeId the client wants posts for (follows + own NodeId).
- Encrypted/GroupEncrypted posts match if ANY recipient / group
member is in query_list (previously only if == requester).
- Wire protocol unchanged — the same PullSyncRequestPayload.follows
field now carries both follow targets and own NodeId indistinguishably.
Client pull paths (all three call sites in network.rs + connection.rs):
- Always append own NodeId to the query list before sending pull sync.
Storage helper:
- get_post_ids_for_recipients(nids) — bulk IN-match using the
idx_post_recipients_recipient index, for future SQL-side filtering.
Tests:
- should_send_post's recipient tests updated to pass query_list
containing requester (matches new contract).
- Added encrypted_post_matches_via_query_list_even_for_third_party_recipient
proving the server matches on any recipient in the list, not just
the requester itself.
All 111 core tests pass. Smoke-tested end-to-end: A posts encrypted
DM to B; B connects + syncs; B decrypts and reads DM; both sides'
post_recipients correctly populated on store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file_holders table is now the only tracker of per-file peer
relationships. post_upstream, post_downstream, blob_upstream, and
blob_downstream are dropped at first launch after the seed migration
copies any existing entries.
Schema:
- DROP TABLE IF EXISTS on all four legacy tables after seeding
- Seed migration guards with sqlite_master table_exists check so fresh
installs don't crash trying to read non-existent sources
- Remove CREATE TABLE statements for the four tables from init
- Remove Protocol v4 Phase 6 post_upstream priority migration (dead)
- Remove blob_upstream preferred_tree column migration (dead)
Rust:
- Remove add/get/remove post_upstream, post_downstream,
blob_upstream, blob_downstream methods
- Remove get_blob_upstream_preferred_tree / update variant
- Rewrite get_eviction_candidates's downstream_count subquery to
count file_holders entries
- Rewrite apply_delete's cascade cleanup to clear file_holders
instead of post_upstream/post_downstream
- cleanup_cdn_for_blob now clears file_holders for the CID
Callers:
- All dual-write sites in connection.rs and node.rs now do
touch_file_holder only (legacy writes removed)
- get_stale_manifests replaced with get_stale_manifest_cids; caller
in node.rs picks a refresh source from file_holders
Tests:
- Remove blob_upstream_crud, blob_downstream_crud_and_limit,
blob_upstream_preferred_tree, remove_blob_upstream,
post_downstream_crud
- Add file_holders_lru_cap and file_holders_direction_promotion tests
All 110 core tests passing. Workspace compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch ALL propagation-decision reads to the flat holder set.
push_manifest_to_downstream now targets file_holders instead of
blob_downstream. ManifestPush receive-side relay likewise — known
holders fan out to up to 5 most-recent peers instead of a directional
tree.
Blob delete notices: single flat fan-out to file_holders; the legacy
upstream_node tree-healing field is emitted as None (wire-stable via
serde default) and ignored on receive — the post-0.6 flat model
doesn't need sender-role distinction. send_blob_delete_notices keeps
its Option<&Upstream> parameter as an unused placeholder for signature
stability with the call sites in this commit.
Other reads migrated:
- blob fetch cascade: step 2 now tries "known holders" (up to 5)
instead of a single upstream
- manifest refresh: downstream_count reported from file_holder_count
- web/http post holder enumeration
- Worm search post/blob holder fallback (both connection.rs paths)
- DeleteRecord fan-out rewires to file_holders
- Under-replication replication check: < 2 holders
Storage additions:
- get_file_holder_count(file_id)
- remove_file_holder(file_id, peer_id)
Legacy upstream/downstream writes are still happening from Phase 2b;
those + the tables themselves go in 2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New flat per-file holder set replaces the directional upstream/downstream
trees. Keyed by 32-byte content-addressed file_id (works for both PostId
and blob CID). LRU-capped at 5 holders per file on touch.
- HolderDirection enum (Sent/Received/Both) — tracked for potential
reuse, not load-bearing for propagation
- touch_file_holder / get_file_holders / delete_file_holders
- seed_file_holders_from_legacy: one-time idempotent seed from
post_upstream, post_downstream, blob_upstream, blob_downstream so
users upgrading from 0.6.0 don't start with empty holder sets
Table and methods land here; call-site refactor and legacy-table drop
follow in subsequent commits within this phase.
Co-Authored-By: Claude Opus 4.7 (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>
spawn_with_arc used blocking_lock() which panics inside tokio runtime.
Changed to async lock().await. Reduced StoragePool from 8 to 4 connections
for Android compatibility.
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>
Security:
- Reaction signatures: ed25519 sign/verify (sign_reaction, verify_reaction_signature)
Backward-compatible — unsigned reactions from old nodes still accepted
- Comment signature verification: verify_comment_signature now called on receipt
- Reaction removal authorization: only reactor or post author can remove
- BlobHeader author verification: lookup actual author from storage, don't trust payload
Lock contention (4 fixes):
- ManifestPush discovery: cm lock released before PostFetch I/O
- Pull request handler: load under lock, filter without lock, brief re-lock for is_deleted
- Pull sender: split into two brief locks (store posts, then batch upstream+sync)
- Engagement checker: batch all chunk results, single lock for writes
Data cleanup:
- Post deletion cleans post_downstream, post_upstream, seen_engagement tables
- Added TODO-hardening.md documenting remaining DOS/security/lock/data issues
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>
Security & stability:
- Incoming auth-fail rate limiting per source IP (3 attempts, then exponential backoff)
- Schema versioning via PRAGMA user_version with migration framework
Networking:
- IPv6 http_addr fix: advertise actual public IPv6 instead of 0.0.0.0
- N2/N3 TTL reduced from 7 days to 5 hours
- Full N1/N2 state re-broadcast every 4 hours
- Bootstrap isolation recovery: 24h check with sticky N1 advertising
- Bidirectional engagement propagation (upstream + downstream)
- Auto downstream registration on pull sync and push notification
- post_upstream table for CDN tree traversal
Media & UI:
- Video preload="auto" for share links and in-app blob URLs
- Following: Online/Offline split with last-seen timestamps
- DMs filtered from My Posts tab
- Image lightbox, audio player, file attachments with download prompt
- Share link unroutable address filtering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>