Commit Graph
102 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug ca6b50ae07 feat: let a user mute a peer's activity in their own feed
Real gap: there was no way to stop a specific peer's noise (new
readings, replies, hearts) from showing up, short of disconnecting
them entirely. Mute is deliberately narrower than a network-level
block: their content is still fully synced and stored (protocol
correctness for the rest of the network is untouched), only the local
social feed and store insertion for their InterpAuthored/AffirmAdded/
ResponseAdded activity is suppressed. Collaborative-doc mechanics,
display names, and circle invites always apply regardless — muting
someone's chatter shouldn't corrupt a shared document or hide a
functional message addressed to you.

- store: new `muted_peers` table + mute_peer/unmute_peer/muted_peers,
  covered by a roundtrip unit test.
- app/feed_item: `is_muted_social_event` is a small pure predicate,
  unit-tested directly (red-first: wrote the five cases, then the
  three-line match) rather than needing network-level BDD coverage
  for what's an entirely local, app-layer concern.
- app: AppModel caches the muted set in memory (loaded once at start)
  so the hot SyncStateEvent path never awaits a DB round-trip; a new
  "Mute"/"Unmute" button on each connected peer's sidebar row (dimming
  their name when muted, mirroring the existing pending-row dim-label
  convention) toggles it.
2026-08-04 20:57:13 +02:00
Bendik Aagaard Lynghaug 826fc1947d feat: let a non-owner circle member leave from the circle page UI
Wires the backend self-leave capability (see the prior BDD commit) into
circle_page.rs: your own row shows "(You)" and, unless you're the
circle's manager, a "Leave circle" button in place of the revoke
control (leaving as the sole manager would orphan the circle, so that
path isn't offered). Leaving asks for confirmation via a destructive
adw::AlertDialog, then drops the now-stale page from the content stack
and navigates back to Chart if it was the visible page.

Also hides the "Revoke access" button on other members' rows unless
the local viewer is themselves a manager — the backend already
rejects a non-manager's revoke attempt, so showing it to everyone was
a dead-end click.
2026-08-04 20:19:48 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 9d8b0cfd31 docs: write 0.10.0 release notes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:17:56 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 c2fe51938f fix: DocAffirmed always reported targets_me=false — real bug in the live model
The legacy InterpOp::Affirm path always correctly computed whether an
affirmation targeted the local user's own content, but StateEvent::
DocAffirmed — the path every current edit actually goes through, since
0.9+ writes exclusively use the collaborative doc model — hardcoded
targets_me = false unconditionally. The bell badge and feed targeting
have never actually detected a heart on your own writing for the
model real users hit today.

The collaborative model has no single "author" the old check relied
on, so added ZodiaStore::doc_has_contributor (does this peer have any
block still in interp_key's author ring) as the closest equivalent —
test-driven, including a case proving the ring holds multiple
contributors rather than getting treated as single-author.

Also surfaces a real-time "X affirmed your reading" toast reusing the
ToastOverlay infrastructure from the circle-invite feature. Per
explicit feedback: purely-informational events like this are a better
fit for a desktop notification (notify::send) than an in-app Toast —
shipping as a Toast for this release since the app is early-stage, and
flagged as something to reconsider if it feels tiresome moving
forward, not changed now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:16:12 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 b7f587dd4a feat: circle-invite toasts with a Join action — the invitee-side gap, closed
Wires the previous commit's backend into the actual app: inviting
someone now also calls notify_circle_invite right after
invite_to_circle succeeds, and the recipient's own client shows an
adw::Toast ("X invited you to a circle") with a Join button — no
timeout, since an invite shouldn't silently expire the way a status
message would. Accepting it calls open_circle, then asks the new
member to name the circle for their own sidebar (the inviter's name
for it is intentionally never synced — see the backend commit's note
on why circle names stay local-only).

Required wrapping the whole window's content in an adw::ToastOverlay
(previously nothing in the app used AdwToast at all) so a toast can
surface from any page, not just one specific tab — general-purpose
infrastructure the next notification-worthy event can reuse without
repeating this setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 22:12:02 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 a083eb915d feat: critical/anaretic degree marker (last degree of a sign)
A ninth gap in the same vein — a placement at 29° of any sign is a
well-known, unambiguous astrological concept (urgency/culmination
themes) that needed nothing beyond the longitude already computed for
every placement. Considered and deliberately skipped void-of-course
Moon in this same pass: it needs real-world validation data (known VOC
windows) I don't have access to, and getting timing advice wrong is a
different risk class than a decorative label — astrologers actually
use VOC for real decisions. Critical degree has no such risk: it's
pure, unambiguous arithmetic on data already computed.

Test-driven against the real edge case (30.0° must read as 0° Taurus,
not critical, not as "still near a 29 boundary" from a sloppy
implementation checking the wrong modulus).

Shown as a "· critical degree" note on the affected placement's degree
label (planets and angles both), plus added to the glyph legend from
the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:59:48 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ba26202948 feat: house cusps table — computed for every chart, never actually shown
chart.houses.cusps has held all 12 house cusp longitudes since houses
were first implemented, but the only place that ever read it was a
stub-detection check (all-zero cusps == geohash too coarse). A full
per-house sign+degree table is a standard chart display every other
astrology tool provides, and this app had the data for it the whole
time with nowhere to put it.

Along the way, added an explicit test for something only ever implied
by other passing tests: House 1's cusp is exactly the Ascendant's
degree in every degree-based system (Placidus/Koch/Equal). The first
version of this test included Whole Sign too and failed — correctly:
Whole Sign houses start at the *sign boundary*, not the Ascendant's
literal degree, a deliberate astrological convention, not a bug. Fixed
by scoping the test to the three systems where the invariant actually
holds, rather than "fixing" correct behavior to match a wrong
assumption.

Surfaced as a new "Houses" section on the Chart tab, hidden for stub
charts (no real houses to show).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:53:22 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 24eb487c7b feat: glyph legend — an ease-of-use gap, not just a missing feature
Every tab shows dense astrological notation (planet symbols, aspect
glyphs, sign glyphs, now also retrograde marks and Moon phases) with
zero explanation anywhere in the app — a real barrier for anyone not
already fluent in the notation, and the opposite of the "genuinely
social, welcoming to newcomers" experience the app is going for.

Adds a "?" button to every tab's header (shared via make_tab_toolbar,
so it's consistent everywhere rather than duplicated per page) opening
one dialog covering all four categories. Built from AspectKind::all()
(newly exposed — the enumeration already existed privately for
detection) and Planet::all(), so it can't silently drift out of sync
with what the app actually renders the way a hand-maintained list
would; a new aspect kind shows up here automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:08:39 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 45563a0c8f feat: detect Grand Trine and T-Square aspect patterns
compute_aspects already finds every individual aspect within orb, but
nothing looked for the specific *combinations* of three that form the
named geometric configurations astrologers specifically watch for.
Adds zodia_core::detect_patterns: Grand Trine (three planets mutually
trine) and T-Square (an opposition, both ends squared by a third —
the "apex"). Test-driven against both the positive shape and the
negative space around it — two legs of a triangle without the third,
or an opposition with only one of the two required squares, correctly
detect nothing rather than a false positive.

Added Ord/PartialOrd to Planet (pure enum, no fields, safe to derive)
so pattern-matching aspect pairs could use a plain canonical
(min, max) ordering instead of a string-comparison workaround.

Surfaced as extra rows in the Chart tab's Balance section alongside
stelliums — silent when a chart has neither pattern, the common case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:03:19 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3c16176fd4 feat: "Big Three" headline (Sun/Moon/Rising) at the top of the Chart tab
Sun sign, Moon sign, and Ascendant sign were each already computed
separately (placements, houses) but never combined into the single
most commonly asked-for astrology summary in casual/pop use ("what's
your Big Three?"). Adds Chart::big_three(), test-driven against the
real bug class this kind of code invites: three near-identical
longitude lookups where a copy-paste swaps which field gets which
body — the test uses three signs far enough apart that a swap would
be caught immediately, not two similar ones that might coincidentally
still pass.

Shown as a single prominent centered line at the very top of the Chart
tab, above Balance and Placements — the first thing a user sees.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:57:05 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 cbbc6532b3 feat: stellium detection (3+ planets clustered in one sign or house)
A fourth classic natal-chart pattern with no prior support — "you have
a stellium in Capricorn" is a well-known astrological observation this
app had no way to surface despite already deriving each planet's sign
and house. Adds zodia_core::stelliums_by_sign/stelliums_by_house
(conventional 3-body minimum), test-driven with synthetic
PlanetPositions so the grouping logic is verified independent of real
ephemeris output. House-based grouping correctly returns empty for
stub charts (all-zero cusps) rather than producing meaningless groups
from house 0 for every planet.

Surfaced as extra rows in the Chart tab's Balance section, one per
detected stellium — silent (no rows) for charts with no stellium,
which is the common case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:52:00 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 2924a799ea feat: elemental/modal chart balance ("Fire-dominant", "a lot of Cardinal energy")
A third fundamental astrology concept Zodia never computed, despite
already deriving each planet's sign for placements. Adds
zodia_core::natal_balance — counts of the 10 tracked planets across
the four classical elements (Fire/Earth/Air/Water) and three
modalities (Cardinal/Fixed/Mutable), derived directly from each sign's
fixed element/modality (no ephemeris dependency, pure classification).
Test-driven against the actual classical mapping table, plus a
dedicated check on Cancer specifically since index 3 is where the
4-sign element cycle restarts — the kind of boundary an off-by-one
would hide in a purely cyclic-pattern test.

Surfaced as a new "Balance" section on the Chart tab, above Placements
— two plain summary rows, no InterpKey plumbing needed since this is a
derived summary rather than contributable content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:46:30 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 9de665daa8 feat: Moon phase — natal and live, another concept the app never tracked
Same gap as retrograde: Zodia already computes Sun and Moon longitude
for every chart, but never derived phase from their angular separation
despite it being one of the most universally recognized astrology
concepts. Adds zodia_core::moon_phase (8-phase enum from the Sun-Moon
separation angle), test-driven: the first test revealed a real bug in
the *test's* own scan logic, not the implementation — New Moon's 45°
segment straddles the 0/360 wraparound, so a naive linear scan across
one cycle sees it twice non-contiguously. Fixed by checking phase
reachability as a set instead of an artificial "N contiguous runs"
count, which was fighting the circularity rather than testing it.

Surfaced in two places: the natal Placements section now shows "Born
under a [phase] Moon" (a real, commonly-discussed birth-chart trait),
and the Sky tab shows today's live phase at the top — useful with zero
natal data at all, and changes slowly enough (~3.5 days between named
phases) that no ticker is needed, just computed once per page build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:39:42 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 0392921877 feat: retrograde indicators on natal chart placements
Zodia tracked zero retrograde-motion awareness anywhere in the
codebase — a real gap for an astrology app, since retrograde Mercury
is one of the most commonly discussed astrological concepts. Adds
zodia_core::is_retrograde(planet, jdn), detected numerically (sign of
the one-day-back longitude delta, handling 0/360 wraparound) since the
simplified ephemeris model has no per-body analytic velocity term.
Verified against real-world retrograde patterns before writing
assertions: Mercury shows ~69 retrograde days/year in this model
(matches its real ~3-4 annual retrograde periods), outer planets
substantially more (Earth laps them yearly), Sun/Moon never (a real
geocentric-astrology invariant, not a model artifact).

Surfaced as a classical "℞" glyph next to any retrograde planet on the
Chart tab's placement rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:27:31 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 216a5a4627 docs: write 0.9.3 release notes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:16:02 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 87e46fa199 fix: surface circles from every aspect page, not just a hidden feed icon
Circles had no real front door — the only way to share into one was to
publish/edit a reading, then go find it in the Sky feed and notice a
small avatar icon on your own card. Adds a "Share to a circle" button
directly on every aspect/placement detail page, reusing the existing
OpenShareToCirclePicker flow (loads the doc's current body when the
page hasn't sent one, same as the feed-card path) — no new backend
plumbing needed, just a second entry point into what already worked.

Also: "Talk about this" (the voice-room button, previously "Start
discussion") silently no-op'd when nobody else was present on that
reading — confirmed by a real user clicking it and seeing nothing
happen. It now sends a notification explaining why, instead of failing
silently. Real fix (disabling the button until presence exists) is
follow-up UI work, tracked as a stopgap for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:12:18 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 e494803534 fix: rename voice feature to stop colliding with Circles, add onboarding name
"Start discussion" / "voice circle" and the Circles privacy feature
both used "circle" in their copy despite being completely unrelated —
confirmed confusing in practice: a real user clicked "Start discussion"
expecting to create a private circle and got a voice room instead.
Renamed to "Talk about this" / "voice room", no circle language left.

Also adds an optional display-name field to first-run setup, right
after the welcome header and before birth details — introducing
yourself before your chart, rather than leaving your own display name
undiscoverable behind a pencil icon on the Network tab (the only place
it lived before). Saved via the same LocalConfig::save_display_name
path the Network tab uses, and broadcast on the first network connect
like any other change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:02:15 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ca087129d8 docs: write 0.9.2 release notes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:32:32 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 e1fbc743db feat: peers can broadcast a self-chosen display name
Adds InterpOp::SetDisplayName (legacy log 0, always-on global topic —
same propagation as Author/Revoke), materialised as
StateEvent::DisplayNameSet and exposed as ZodiaClient::set_display_name.
Receivers persist the newest name per peer (last-writer-wins by the
op's own timestamp, not local receipt time, since local time would let
a replayed stale op incorrectly "win"). The GTK app resolves a shown
name as: local nickname > peer's broadcast name > truncated hex,
threaded through every place a peer's name was previously
hex-only. A pencil button next to the Network tab's status line lets
the user set their own name, persisted via a new display_name.txt in
LocalConfig and re-broadcast on every reconnect.

Also tags every zodia-sdk cucumber test peer with a reserved sentinel
name (zodia_sdk::TEST_PEER_DISPLAY_NAME) and has the app filter any
peer broadcasting it out of the Network tab's discoverable-peers list —
mDNS reaches the whole LAN, so a local test run was showing up as
"new people" in a real running instance on the same network. A local
nickname still overrides the filter, for a developer who wants to keep
a specific test peer visible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:25:07 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 4454a97762 docs: write 0.9.1 release notes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:23:43 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 1250c26fa1 fix: own doc edits/authored content now shareable to a circle
The circle-share button only ever appeared on InterpAuthored cards
authored by the local user, but nothing in the GTK app calls author()
— PublishDocEdit is the only path, which produces DocEdited events.
Worse, DocEdited was never pushed to the editor's own feed (the
pipeline only echoes it back from peers), so a user's own edits never
appeared in their own feed at all. Net effect: the circle-sharing
feature was unreachable from the shipped UI.

Push a local FeedItem on PublishDocEdit (mirroring the existing
doc_rolled_back pattern), and let the share button also fire for own
DocEdited cards, loading the doc's current body on demand since that
payload carries no body of its own.

Also adds a BDD scenario proving revoke_from_circle (already
implemented but untested) actually stops a removed member from
reading subsequently shared content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:27:39 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 a7d1f0f25a docs: refresh app screenshots, add circles
Chart/network/synastry recaptured against the current UI; adds a new
circles screenshot showing a real circle with an invited member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:27:27 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 44aa02a695 docs: write 0.9.0 release notes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:03:45 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 d7c542592d feat: circle accent colors + read/write choice at invite time
Deterministic per-circle accent color (Signal/Matrix-style avatar
colors), derived from the circle id hash so it needs no storage of
its own - a small dot in the sidebar row and circle page header via
Pango markup, no CssProvider plumbing needed for a one-off color.

Invite now offers two buttons instead of one: read-only (the common
case) or read+write, turning a one-way share into a small
collaborative circle where invitees can post their own interpretations
back - same InterpOp::Author + share_interp_to_circle plumbing either
way, just a different Access level at invite time. No in-place
promotion yet: p2panda-auth's own add() errors with AlreadyAdded on an
existing member, so changing someone's level today means revoke then
re-invite (confirmed by reading the source, not assumed) - a real,
undocumented-until-now UX gap, not implemented in this pass.

Verified: full workspace build + test suite green, plus a second real
GUI smoke test confirming the accent dot renders correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:58:42 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 4c126ec43f feat: circles GTK UI — sidebar section, per-circle pages, share flow
Circles get their own sidebar section (own rows, one per circle),
positioned right below Chart/Sky/Network and above the "Others" (direct
peer) section — no bundled management tab, per direction mid-session.
Clicking a row (re)builds and shows that circle's page (plain
widget-builder function, mirroring stargazer_page.rs rather than a
relm4 sub-Component, since it only needs to fire AppMsgs on click):
member list with per-row revoke, and an "Invite" list of known
connected peers not already members.

Circle creation has no standalone form — it's folded into the
share-to-circle flow itself ("born out of" whatever's being shared):
the new share button on a feed card's own contributions opens a picker
(existing circles, or type a new name to create-and-share in one step).

New local-only persistence: circles.tsv (circle_id_hex -> name), same
flat-TSV pattern nicknames.tsv already uses for "hash has no name".

Verified: full workspace build + test suite green (including the
16-scenario circle-sharing cucumber suite), plus a real GUI smoke test
(Xvnc + screenshot) confirming the app launches clean and the Circles
sidebar section renders correctly and in the right position. Full
interactive click-through (invite/share dialogs) wasn't verified live
— no input-automation tool (xdotool etc.) was available in this
sandbox to drive clicks, only screenshot capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:15:33 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 0db157220b feat: upgrade p2panda 0.6->0.7 (+ iroh 1.0), prerequisite for zodia-circles
zodia-circles needs p2panda-encryption for real group encryption, which
only ships at 0.7.0 and requires p2panda-core ^0.7.0 — the original
Phase D PRD's premise that this "wraps p2panda 0.6's group-encryption
primitives" was wrong, that crate doesn't exist at 0.6.x.

Header::timestamp was dropped upstream; replaced with zodia-ops's
OpExtensions carrying a Timestamp via the Extension trait, matching
p2panda-core's own documented extensions pattern. OperationStore lost
its log-id generic parameter, SyncHandle::publish became sync, SeqNum
narrowed to u32. The operations_v1.timestamp column was also dropped
from p2panda-store's schema, which broke prune_older_than at runtime
(caught by the existing pruning.feature cucumber scenarios, not by
compilation) — fixed by decoding the still-present header CBOR blob
per candidate row instead of filtering by a now-nonexistent column.

Full workspace test suite + the 16-scenario zodia-sdk cucumber suite
pass unchanged. Details in docs/prd/p2panda-0.7-migration.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 08:45:23 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 0d362c12d5 docs: add rasterized 512x512 PNG of app icon
Standalone asset for non-vector use (README, sharing). Rasterized
from the same canonical SVG the GTK icon theme and macOS CI packaging
already use directly — that pipeline is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 06:49:54 +02:00
Bendik Aagaard Lynghaug 3deb73d7e7 feat: wire grace-period unsubscribe into app.rs, closing out C-2
Completes docs/prd/granular-topic-subscription.md - the last unshipped
piece was app.rs actually calling touch_subscription when a
non-chart aspect page opens. The SDK primitive shipped two commits
ago; nothing called it yet.

BDD-driven: wrote needs_lazy_subscription(interp_key, chart) - a pure
function deciding whether a key is already permanently subscribed via
the user's own chart (false) or needs its own grace-limited
subscription (true) - and its two Given/When/Then tests first,
against a function that didn't exist yet (compile failure = red),
then implemented it and wired it in (green). 4 tests now in the app
crate's bin target, up from 2.

Wired via the same site that already announces EditorPresence join on
page open (aspect_view.rs's build_doc_reading_group) - sends
AppMsg::TouchKeySubscription, handled in app.rs by checking
needs_lazy_subscription and calling client.touch_subscription(key,
600s) if needed. 600s reuses transit_ticker.rs's existing
TICK_INTERVAL rather than a new constant.

Documented a real, deliberate deviation from the original PRD sketch:
touch_subscription's grace clock starts at first touch (page open),
not at leaving, since the SDK's current API can't cleanly distinguish
"still viewing" from "touched once." A single continuous page visit
longer than 10 minutes would let the subscription lapse mid-visit -
accepted as good-enough given typical visit lengths, with a documented
fix path (periodic re-touch) if it turns out to matter.

Verified: full workspace build clean, all tests green (14/14 cucumber
scenarios, 4/4 app-crate tests), and the compiled binary re-launched
under a real session a third time - ran stably for ~40 real minutes
before an unrelated sandbox pause/resume disrupted its network
namespace (gossip/iroh errors, no panics, no application-level
failures) - not a regression, the app handled it as designed
(retrying, then exiting cleanly rather than hanging).
2026-07-26 09:34:12 +02:00
Bendik Aagaard Lynghaug 76d0775340 refactor: delete dead legacy write path, correct the revoke finding
Full-crate reachability check (every AppMsg variant, not just the
files touched by the migration) found AppMsg::{ShareInterp,
AffirmInterp, SubmitInterp, SubmitResponse} are never sent by any live
widget - the legacy "competing whole interpretations" write UI was
already fully superseded by the collaborative-doc model and had no
surface left to trigger it from. Deleted all four from app.rs, and
correspondingly ZodiaClient::affirm()/respond_to() + their Command
variants from zodia-sdk, since nothing in the workspace called or
tested them once the dead handlers were gone.

Correction: this also fixes the finding that started the app.rs->SDK
migration two commits ago. An earlier, narrower audit (grep across
only aspect_view.rs and app.rs) concluded SubmitRevoke had zero UI
trigger. Wrong - just an incomplete grep that missed feed_view.rs,
which has a real working "Revoke and delete" button on activity
cards, wired end to end. author()/revoke() were kept in zodia-sdk:
both have live call sites and real cucumber coverage
(author_propagation.feature, revoke_propagation.feature). The SDK
migration was still worth doing regardless (it fixed two real
architectural gaps - attach() and per-peer sync lifecycle events) but
the specific capability gap that motivated starting it didn't exist.

Verified: full workspace build clean (zero warnings), all tests green
(14/14 cucumber scenarios), and the compiled binary re-launched under
a real X11/Wayland session a second time post-cleanup - stable, no
panics, clean shutdown.
2026-07-25 16:20:07 +02:00
Bendik Aagaard Lynghaug 091b9aef86 feat: migrate app.rs onto zodia-sdk, deleting the old direct plumbing
Completes the migration docs/prd/zodia-sdk.md always intended.
SyncPublishMsg, try_spawn_sync's hand-rolled ZodiaSyncNode+ZodiaPipeline
wiring, and the direct zodia_sync/zodia_pipeline imports in app.rs are
gone. app/Cargo.toml no longer depends on zodia-sync at all.

try_spawn_sync now calls ZodiaClient::attach(&net, ...) and bridges
client.events()/client.sync_lifecycle_events() into
AppMsg::SyncStateEvent/AppMsg::SyncLifecycle via tokio::spawn tasks -
same message types the rest of the app already handles, so no
downstream changes needed there. All 9 SyncPublishMsg::{Publish,
PublishDoc} call sites now call the matching ZodiaClient method
directly and await a real Result (logged on error) instead of
try_send-and-forget. subscribe_own_chart_keys awaits client.subscribe
per key.

Two real gaps in zodia-sdk found and fixed by actually attempting this
migration, not by design review:

1. ZodiaClient::connect always spawned its own ZodiaNetwork. app.rs
   also needs ZodiaNetwork directly for Tier-1 consent/chat/AV, which
   this SDK doesn't cover - two independent ZodiaNetworks under the
   same identity would be wasteful and wrong. Added ZodiaClient::attach
   (&net, signing_key, data_dir), reusing an existing network's
   endpoint/gossip. run() restructured around a NetworkSource::
   {Owned,Attached} enum - the Owned case must keep ZodiaNetwork bound
   for run()'s whole lifetime, not just setup, since dropping it early
   tears down discovery/mDNS.
2. sync_status() only exposed aggregate counts, but app.rs's existing
   Network tab needs raw per-peer SyncStarted/Finished/Failed with
   remote_pk and received_ops. Added SyncLifecycleEvent +
   sync_lifecycle_events(), broadcast alongside the aggregate.
3. Two legacy ops (InterpOp::Affirm, InterpOp::RespondTo) had no
   ZodiaClient method at all - this SDK's original scope missed two
   live app.rs call sites. Added affirm() and respond_to().

Two new zodia-sdk unit tests cover attach() specifically, proving it
carries real sync traffic and not just constructs. Verified beyond
compilation: the compiled binary was actually launched under a real
X11/Wayland session (XDG_DATA_HOME pointed at a scratch dir, no real
user data touched), completed setup, spawned network via attach()
successfully, ran stably for several minutes with no panics, shut down
cleanly. Full workspace build + all tests (unit, integration, 14
cucumber scenarios) green throughout.

Still open: affirm()/respond_to() have no cucumber coverage yet
(added under time pressure to unblock this migration); the Lagged
backpressure test is still open too.
2026-07-25 15:54:37 +02:00
Bendik Aagaard Lynghaug 10c51c33f6 feat: Phase C-2 log-splitting — per-key logs + sync topics
Core of docs/prd/granular-topic-subscription.md: derive p2panda log_id
from interp_key (zodia_ops::log_id_for_key) instead of the hardcoded
INTERP_LOG_ID=0, and add a per-key topic (topic_key_for_interp) so
subscribing to a key's topic actually scopes what replicates.

ZodiaSyncNode now holds a topic->SyncHandle map instead of one fixed
handle; subscribe/unsubscribe open and drop per-key topics on demand.
publish_doc (DocOp, the active write path) routes through the derived
log/topic; publish (legacy InterpOp) stays on log 0 over the global
topic, since existing signed ops can't be re-homed to a new log_id.

App wires the always-subscribed set: every key in the user's own
natal chart is subscribed right after sync spawns, on both cold-start
paths. On-demand subscribe for arbitrary aspect pages, the
grace-period unsubscribe timer, and mock-backed ZodiaSyncNode tests
are not yet implemented — tracked in the PRD's progress notes.
2026-07-24 21:45:42 +02:00
Bendik Aagaard Lynghaug 017ea43d31 docs: reconcile v0.7.1 version-cut with collaborative-interp PRD
DocOp is a wire-format-incompatible addition that the PRD planned to
ship as 0.9.0 with a known-incompat note; it actually shipped as the
0.7.1 patch alongside the activity feed. Decode-time behavior is safe
(unrecognised ops are skipped, not fatal) but the version number
didn't carry the signal the PRD intended. Record what happened in the
PRD and add a compat note to the already-published 0.7.1 release
description.
2026-07-24 19:08:32 +02:00
Bendik Aagaard Lynghaug b6b4d9c8e3 docs: fill in v0.7.1 release notes 2026-07-24 19:04:19 +02:00
Bendik Aagaard Lynghaug 3b81eb1744 release: bump to v0.7.1
Release / Create GitHub Release (push) Failing after 7s
Release / Build x86_64-unknown-linux-gnu (push) Has been skipped
Release / Build aarch64-unknown-linux-gnu (push) Has been skipped
Release / Build aarch64-apple-darwin (push) Has been skipped
Release / Build x86_64-pc-windows-gnu (push) Has been skipped
2026-07-24 19:02:59 +02:00
Bendik Aagaard Lynghaug 164a749e4d feat: activity feed, collaborative interpretations, transit house windows
Sky tab becomes a live activity feed (feed_view/feed_item/transit_ticker)
backed by store-side feed rows, with notification bell badge wiring.

Add zodia-doc crate: Loro-backed collaborative interpretation documents
with an author-veto ring, used by aspect_view and the store for shared
edit history.

core: add house_transit_window for house-based transit windows, and
Planet::from_name for parsing interp keys.

Adds PRDs for activity-feed, circles-mvp, and collaborative-interpretations.
2026-07-24 18:57:44 +02:00
Bendik Aagaard Lynghaug de0cccd75d fix line height of aspect list 2026-05-30 17:25:40 +02:00
Bendik Aagaard Lynghaug 157806ec61 release: 0.7.0 metainfo notes 2026-05-30 17:20:26 +02:00
Bendik Aagaard Lynghaug 92a440d27e phase C: causal contribution threads
RespondTo is now wired end-to-end so peers can riff on each other.  A
response is its own first-class interpretation that hangs off a parent
via parent_log_id (BLAKE3 content-hash, same targeting model as Affirm).
Persisted in the existing interpretations table with a new
parent_log_id BLOB column; orphan responses (parent not yet known
locally) are still kept so they materialise once the parent arrives
via sync.

Pipeline gives us StateEvent::ResponseAdded already from Phase A; this
commit makes it do real work instead of being a no-op.  The app side
handles SubmitResponse for the authoring path: local insert via
store.insert_response_from_op + sync publish as InterpOp::RespondTo,
so any peer subscribed to the global topic will see and persist the
same row.

Wire-format cleanup: RespondTo.parent_op_id renamed to parent_log_id
for consistency with Affirm.target_log_id.  Both target content
hashes, not p2panda operation hashes (the rationale we settled on
when starting Phase B).

Store:
  * New column parent_log_id BLOB on interpretations (best-effort
    ALTER on init for legacy DBs)
  * insert_response_from_op(parent_log_id, body, author_pk) — derives
    the response log_id, copies the parent interp_key when known
    ("response" kind) or stores empty key with "response_orphan" kind
    until the parent arrives
  * responses_for(parent_log_id) — returns InterpRows ordered by
    received_at ASC for chronological thread display
  * row_to_interp_row helper extracted from the existing fan-out

UI (aspect_view.rs):
  * Each community parent row gets a "💬" Respond button alongside the
    Affirm button (baselines remain affirm-only, no response button)
  * Click opens an adw::AlertDialog with a text entry; submit fires
    AppMsg::SubmitResponse
  * Responses appear immediately below their parent as smaller indented
    ActionRows tagged with the responders 4-hex author tag, pre-fetched
    from store.responses_for during detail-page build
  * Live refresh after a fresh response submit isnt wired yet — the
    user has to navigate away and back to see it.  Documented in the
    function comment; will land naturally when Phase D moves UI state
    onto the pipeline.

Tests: 27 passing across the workspace.
2026-05-30 12:46:46 +02:00
Bendik Aagaard Lynghaug 4922b16927 fix: auto-accept incoming channel from already-Connected peers
After mutual consent has been exchanged once, future IncomingChannel
events from the same peer should not require another user click — the
peer is already in the trusted set, the only thing happening is a
reconnection because one side restarted, roamed, or temporarily lost
NAT state.

Before this commit the auto-accept fast path only matched the
OutgoingPending case (the mutual-pending happy path).  Persisted
Connected peers fell through to the "incoming consent request — waiting
for user approval" branch on every reconnect, causing the auto-
reconnect loop initiated by PeerDiscovered to time out unless the user
manually re-accepted on the receiving side.

Visible in the user-supplied log as repeated "consent exchange: receive
error: closed by peer: 0" on the side that initiated the reconnect:
the other side was sitting on a notification waiting for a click.

Fix: model the two valid auto-accept reasons explicitly via a small
AutoAcceptKind enum.  FirstTime preserves the old behaviour for
OutgoingPending (notify-on-connect, persistence write).  Reconnect
sets is_new=false so ConnectionComplete suppresses the notification
and skips the redundant save_stargazers write.

No new schema, no wire-format change.
2026-05-30 11:37:28 +02:00
Bendik Aagaard Lynghaug ef8d178c31 phase B: surface LogSync session lifecycle in the Network tab
Per-peer sync status (Syncing… / Caught up · N ops received / Failed: …)
   now shows up as a 'Sync activity' group at the bottom of the Network
   tab.  Makes the p2p layer legible — users see which Zodia identities
   their device is exchanging the community log with, and roughly how
   much arrived.

   Pipe:

     TopicLogSyncEvent::{SyncStarted,SyncFinished,Failed}
       → SyncEvent::{SyncStarted,SyncFinished,Failed}  (zodia-sync)
       → AppMsg::SyncLifecycle(SyncLifecycle)          (pump task)
       → AppModel.sync_peer_status updated             (update handler)
       → NetworkTabMsg::Refresh.sync_status            (send_network_refresh)
       → 'Sync activity' adw::PreferencesGroup         (network_tab update_view)

   Changes:

     * zodia-sync: replace the operations-only `inbound_ops: Receiver<Operation>`
       with `inbound: Receiver<SyncEvent>`.  Subscription task forwards both
       OperationReceived and the lifecycle variants (SyncStarted / SyncFinished
       with received_ops + received_bytes from Metrics / Failed with error).
       p2panda-core::VerifyingKey added to imports for the lifecycle variants.
     * app: new SyncLifecycle enum + SyncPeerStatus enum.  AppMsg gains
       SyncLifecycle(SyncLifecycle).  AppModel gains a sync_peer_status
       HashMap<[u8;32], SyncPeerStatus>.  Pump task forks: OperationReceived
       feeds the pipeline as before; lifecycle goes straight to the new
       AppMsg.  Lifecycle handler updates the map and bumps
       network_changed_token so the refresh routes the new data into the tab.
     * network_tab: NetSyncStatus row data + Refresh.sync_status field +
       a rendered 'Sync activity' group (titled, described, one ActionRow
       per peer).

   Tests: 27 passing.  No new unit tests for the lifecycle wiring — it's
   straight enum routing with no logic worth fixturing; smoke-testable on
   the device with two app instances pairing.
2026-05-30 11:17:27 +02:00
Bendik Aagaard Lynghaug dcbaccc429 phase B: network-replicated affirmations
Affirmations (♡) now flow through the same LogSync gossip layer as
authored interpretations.  When a user affirms, two things happen:

  1. Local: store.affirm(log_id, our_pk) — UI counts update immediately.
  2. Network: InterpOp::Affirm { target_log_id } published via sync.
     Peers' StateEvent::AffirmAdded handlers mirror the row into their
     local affirmations table, so counts converge across the network.

Sybil resistance is (target_log_id, voter_pk) uniqueness, enforced by the
existing affirmations table's UNIQUE constraint.  Duplicate Affirm ops
from the same voter are silently dropped via INSERT OR IGNORE.

Targeting model: Affirms reference the Zodia content hash log_id
(BLAKE3(interp_key || body)), not the p2panda operation hash.  Means
duplicate-content authorings from different peers collapse to one
community row with combined affirmations — better UX, and zero schema
migration for what was already content-keyed locally.

Field renames in zodia-ops + zodia-pipeline:
  - InterpOp::Affirm.interp_op_id → target_log_id
  - StateEvent::AffirmAdded.interp_op_id → target_log_id
  - Dropped the running_count from AffirmAdded — the in-memory count was
    only what the materializer had seen since startup, not a meaningful
    convergence signal.  Store is the source of truth.

Side fix: net/tests/channel.rs (broken since the 0.6.0 iroh 0.98 bump,
not covered by cargo build) updated to use the new iroh::Endpoint
preset API.  Endpoint::builder(Minimal) for loopback-no-relay tests.

Tests: 27 passing workspace-wide.  Two new assertions in zodia-pipeline
that two distinct voters produce two AffirmAdded events with the right
target_log_id + voter pubkey.
2026-05-29 23:56:55 +02:00
Bendik Aagaard Lynghaug 36fb5c886b phase A wire-up: LogSync → ZodiaPipeline → AppMsg
Plumbs the inbound LogSync stream through ZodiaPipeline.  The op flow
   end-to-end:

     remote peer → LogSync sub → node.inbound_ops → pipeline.process(op)
       → pipeline.next() → AppMsg::SyncStateEvent → store.insert_from_op
       → recent_interps refresh + UI bump

   Publish goes the other way: AppMsg::ShareInterp now packages the entry
   as InterpOp::Author and forwards it to node.publish(op), which CBOR-
   encodes, signs the p2panda header, persists to the operation log, and
   as InterpOp::Author and forwards it to node.publish(op), which CBOR-
   encodes, signs the p2panda header, persists to the operation log, and
   broadcasts via LogSync.

   Changes:

     * zodia-sync: drop InterpPayload (legacy Zodia-sig wrapper), drop
       ReceivedInterp, drop the ZodiaStore dep entirely.  Expose raw
       Operation<()> on `inbound_ops` and accept InterpOp on publish.
       The subscription task is now a thin forwarder — decoding lives in
       the pipeline.
     * zodia-store: add insert_from_op(key, body, author_pk) that trusts
       its caller (the pipeline) for authentication and leaves author_sig
       NULL.  Tier-1 community_for_keys still filters on
       author_sig IS NOT NULL so re-sharing is unaffected.
     * app: SyncInterpReceived → SyncStateEvent(StateEvent); the
       SyncPublishMsg::Publish payload becomes a plain InterpOp; the pump
       task moves from tokio::spawn to glib::MainContext::default()
       .spawn_local because ZodiaPipeline is !Send (single-threaded by
       design).
     * sync/Cargo.toml: zodia-store dep removed, zodia-ops dep added.

   Wire-format break: peers on this branch encode InterpOp::Author instead
   of the legacy InterpPayload struct.  Old peers' bodies will produce
   StateEvent::Skipped { MalformedOp } and be dropped silently.  That's
   acceptable for a feature branch; the release note will flag it when
   this lands.

   Workspace builds clean.  8/8 unit tests pass across zodia-ops +
   zodia-pipeline.
2026-05-28 21:21:12 +02:00
Bendik Aagaard Lynghaug 92d65f6303 release: 0.6.0 metainfo notes 2026-05-28 20:39:24 +02:00
Bendik Aagaard Lynghaug 2481f1f45b fix: sync interpretations on every reconnect + auto-retry on rediscovery
Three small bugs in the consent + sync flow that pre-dated the migration
but stand out now that LogSync 0.6 is back online:

1. do_interp_sync was gated on is_new=true in ConnectionComplete.  After
   the first consent, a peer reconnecting later wouldn't re-share its
   relevant-to-this-pair community entries.  Now runs every successful
   connect.

2. ZodiaNetEvent::InterpReceived (live Tier-1 InterpShare arrivals) wrote
   to the DB but did not refresh self.recent_interps or bump the change
   token, so the network tab's activity feed stayed stale until the user
   triggered some other refresh.  Now updates both.

3. PeerDiscovered only re-issued AppMsg::Reconnect for OutgoingPending
   peers.  A previously-Connected peer who came back online wouldn't
   trigger a reconnect even though their announce was being received.
   Now retries when the peer's state is Connected and we don't already
   have an active channel — so simply having both apps up + on the same
   network is enough for the connection to come back.
2026-05-28 20:35:53 +02:00
Bendik Aagaard Lynghaug b8489dc024 phase 3: rewire ZodiaSyncNode against LogSync 0.6
Real sync layer back, now backed by a persistent p2panda-store SqliteStore
at $DATA_DIR/sync_log.db (separate from interpretations.db so we don't have
to share schemas with the rusqlite-era zodia-store rewrite).

API churn handled:
- MemoryStore → SqliteStore (persistent; LogStore + TopicStore impls come
  from p2panda-store, no custom TopicMap needed anymore)
- LogSync::builder(store, topic_map, endpoint, gossip) → builder(store, endpoint, gossip)
- TopicLogSyncEvent::Operation(op) → ::OperationReceived { operation, metrics }
- TopicLogSyncEvent::SyncStarted(_) → ::SyncStarted { metrics }
- TopicLogSyncEvent::SyncFinished(_) → ::SyncFinished { metrics }
- Header.public_key → verifying_key; Header.previous field removed
- Header.timestamp now Timestamp (use Timestamp::now())
- get_latest_entry / insert_operation now live on LogStore/OperationStore traits

Side effect: offline-catch-up of community interpretations is back online.

ZodiaSyncNode::spawn now requires the data_dir path so it can place its log
DB alongside the main store; caller updated.
2026-05-28 20:18:51 +02:00
Bendik Aagaard Lynghaug 96dcb6daca phase 2: bump iroh 0.96 → 0.98 and p2panda 0.5 → 0.6
API churn handled:
- p2panda_core::PrivateKey → SigningKey, PublicKey → VerifyingKey
- iroh_endpoint::Builder::private_key() → .signing_key()
- gossip.stream(): now takes Topic instead of [u8; 32] — use Topic::from(key.0)
- p2panda-core 0.6 in zodia-crypto + zodia-app

zodia-sync is stubbed: the new LogSync API (SqliteStore-backed, TopicStore
trait, restructured FromSync events) needs a real rewrite that I'm deferring
to Phase 3. The stub exposes the same public surface (spawn/publish/
ReceivedInterp) but performs no actual sync, so live Tier-1 direct exchange
still works while offline catch-up of community interpretations is paused.

Workspace builds clean. Smoke-test before piling Phase 3 on top.
2026-05-28 19:59:41 +02:00
Bendik Aagaard Lynghaug a60b68b894 phase 1 complete: aspect_view → SimpleAsyncComponent
Convert AspectView to SimpleAsyncComponent so init+update can await the
async store. Affirm/submit clicks route through AppMsg::AffirmInterp /
AppMsg::SubmitInterp so the parent handles the actual store write on its
own runtime — the click closure just dispatches the message and updates
the UI optimistically.

Drop the dangerous _blocking helpers on ZodiaStore (would have panicked
on GTK main thread since GTK callbacks run outside tokio context). Last
sync callsite — the chat-history preload in AppModel::init — now uses
direct .await inside the async init.

Workspace builds clean. Phase 1 (storage migration) is done; rusqlite is
gone, sqlx-sqlite is the only SQLite linker, p2panda 0.6's libsqlite3-sys
dep no longer conflicts. Next: Phase 2 — bump iroh 0.96 → 0.98 + p2panda
0.5 → 0.6, then Phase 3 (replace ZodiaSyncNode with the new Node API).
2026-05-28 19:44:14 +02:00
Bendik Aagaard Lynghaug aaf23c3170 wip: route affirm + submit interp through AppMsg (parent-handled async) 2026-05-28 19:25:56 +02:00
Bendik Aagaard Lynghaug a7445cf704 wip: phase 1 storage migration — sqlx async store
Migrate zodia-store from rusqlite to sqlx-sqlite to unblock p2panda 0.6
(which also links sqlite3 via sqlx, conflicting with rusqlite's libsqlite3-sys).

zodia-store and zodia-sync compile cleanly; ~10 build errors remain in
app/src/aspect_view.rs and stargazer_page.rs where sync GTK widget init +
click closures need to read/write the now-async store. Next step is to
pre-fetch in async parent (AppModel) and pass plain data into the sync
SimpleComponent — not yet done.

WIP checkpoint on port/p2panda-node-api; do not merge to main.
2026-05-28 19:22:18 +02:00
Bendik Aagaard Lynghaug 70f080bd13 net: drop self-hosted relay + bootstrap; use iroh defaults
The self-hosted iroh-relay at stargaze.whatdoyouliketodo.com was no longer
  reachable (Caddy up, iroh-relay daemon down), producing 'Failed to connect
  to relay server' warnings on every startup.  Remove RELAY_ZODIA,
  ALL_RELAYS, and BOOTSTRAP_NODE_ID and let iroh use its public default
  relays.  Delete the zodia-bootstrap binary and service unit — no longer
  needed.
2026-05-28 17:50:16 +02:00