Skip to content

Add channel visibility & ephemeral TTL controls to manage sidebar#911

Merged
wesbillman merged 4 commits into
mainfrom
brain/channel-controls
Jun 9, 2026
Merged

Add channel visibility & ephemeral TTL controls to manage sidebar#911
wesbillman merged 4 commits into
mainfrom
brain/channel-controls

Conversation

@wesbillman

Copy link
Copy Markdown
Collaborator

Summary

Adds the ability to edit a channel's visibility (open/private) and its ephemeral TTL after creation, plus a sticky footer so the lifecycle buttons (Archive/Unarchive/Leave/Delete) stay visible in the channel manage sidebar. Requested by @wes.

Previously these fields could only be set at channel-creation time; the kind:9002 edit-metadata path didn't know about them.

What changed

Relay (minimal slice)

  • kind:9002 handler now recognizes new visibility and ttl tags, validates them (visibility ∈ open/private; ttl = positive seconds, or "" to clear → permanent), gates them owner/admin-only (same tier as name/about/archived), persists, resets ttl_deadline = NOW() + ttl on ttl change, and emits visibility_changed / ttl_changed system messages.
  • A visibility flip invalidates the accessible-channels cache — without this, a freshly-private channel would stay visible to non-members until the cache expired.
  • sprout-db ChannelUpdate gains visibility and a tri-state ttl_seconds (Some(Some(n))=set, Some(None)=clear, None=untouched). Clearing nulls both ttl columns.

Desktop

  • Sidebar lifecycle section: a Private switch, an Ephemeral switch, and a friendly duration field (1d/12h/30m, combos like 1d12h) shown when ephemeral is on, with inline validation and a dirty-aware "Save visibility" button (only enables when something actually changed and the duration is valid; an empty/invalid field can't accidentally clear an existing TTL).
  • Sticky border-t footer for Archive/Unarchive/Leave/Delete, pulled out of the scroll area.
  • Friendly 1d/12h/30m parse/format lives entirely in the web layer; the wire protocol stays numeric seconds, matching channel creation.
  • The tauri command uses a double_option deserializer so a present null survives serde as Some(None) (clear) rather than collapsing to None (no-change).

Verification

  • Pre-commit: rustfmt, tauri-fmt, biome, file-sizes, mobile analyze — all ✓
  • Pre-push full suite: rust-tests, rust-clippy, desktop-test, desktop-tauri-test, desktop-tauri-clippy, mobile-test — all ✓
  • New unit tests: ephemeralChannel parse/format + round-trip; SDK builder set/clear/invalid-visibility/no-fields.

The relay validation and the sprout-db update SQL are exercised by the Rust test suite + CI (live Postgres paths) rather than additional inline unit tests.

🤖 Adversarial review requested from @Pinky in parallel.

Adds the ability to edit a channel's visibility (open/private) and its
ephemeral TTL after creation, and pins the lifecycle buttons in a sticky
footer so they stay visible.

Relay (minimal slice):
- kind:9002 handler recognizes new `visibility` and `ttl` tags, validates
  them (visibility in open/private; ttl = positive seconds or "" to clear),
  gates them owner/admin-only (same tier as name/about/archived), persists,
  resets ttl_deadline on ttl change, and emits visibility_changed /
  ttl_changed system messages. Visibility flips invalidate the
  accessible-channels cache.
- sprout-db ChannelUpdate gains `visibility` and tri-state `ttl_seconds`
  (Some(Some(n))=set, Some(None)=clear -> permanent, None=untouched).

Desktop:
- Sidebar lifecycle section: Private switch, Ephemeral switch, and a
  friendly duration field (1d/12h/30m, combos like 1d12h) shown when
  ephemeral is on, with inline validation and a dirty-aware save button.
- Sticky border-t footer for Archive/Unarchive/Leave/Delete.
- Friendly 1d/12h/30m parse/format lives entirely in the web layer; the
  wire protocol stays numeric seconds. Tauri command uses a double_option
  deserializer so null=clear survives serde.

Note: relay validation and the sprout-db update SQL are exercised by CI
(they need a live Postgres) rather than inline unit tests.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman requested a review from a team as a code owner June 8, 2026 21:21
@wesbillman

Copy link
Copy Markdown
Collaborator Author

Screenshots — channel visibility & ephemeral TTL controls

Captured from the desktop app via the Playwright e2e harness (channel-controls-screenshots.spec.ts, mock backend, owner-on-general). Real renders, 6/6 tests green.

Full management sheet

Private + Ephemeral controls in the lifecycle section, with the sticky footer (Leave / Archive / Delete) pinned at the bottom.

Management sheet

Private toggle

Flip a channel private after creation (owner/admin only).

Private on

Ephemeral + friendly TTL

Ephemeral on with a friendly duration field (1d, 12h, 30m, combos like 1d12h). Changing it resets the deletion countdown.

Ephemeral TTL

TTL validation

An invalid/empty duration blocks save with an inline error — can't accidentally clobber an existing TTL.

TTL invalid

Sticky footer

Archive / Leave / Delete pulled out of the scroll area into a border-t footer pinned to the bottom of the sheet.

Sticky footer

Default lifecycle state

Both switches off, as a baseline.

Lifecycle default

wesbillman and others added 3 commits June 8, 2026 15:31
Captures the new channel lifecycle controls (Private/Ephemeral switches,
friendly TTL field with validation) and the sticky Archive/Leave/Delete
footer via the existing Playwright screenshot harness on the mock backend.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
When a channel flips from open to private, connections that subscribed
while it was open kept receiving events: fan-out does not re-check channel
access per event, so a non-member's live subscription leaked private
messages. Detect the open->private transition and close every live
channel-scoped subscription held by a connection whose authenticated
pubkey is not a current member; members keep their subscriptions.

Also harden TTL editing to fail closed: a parse failure now rejects the
edit instead of silently clearing the TTL to permanent.

- subscription.rs: channel_subscriber_conns enumerates distinct conns
  subscribed to a channel across the kind and wildcard indexes.
- state.rs: pubkey_for_conn forward lookup (conn -> authenticated pubkey).
- side_effects.rs: shared per-conn eviction helper reused by both the
  member-remove path and the new non-member eviction path.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Stale subscriptions could survive an open->private visibility flip on
other relay nodes, leaking private-channel events. Add a fan-out access
filter applied at both the local dispatch and the multi-node Redis
consumer: channel-less and open-channel events pass through untouched;
on a private channel a recipient is kept only if its connection's
authenticated pubkey is a current member. Unknown/unauthenticated
recipients and visibility-lookup errors fail closed.

The per-channel visibility cache caches only `private`, never `open`.
The gate fails open on a non-private result, so a stale cached `open`
on another node would mask the filter for the whole TTL after a flip
(the cache has no cross-node invalidation). Caching only `private`
keeps it fail-safe: the worst stale entry is an over-restrictive
`private`, never a leak. Open-channel events cost one channel lookup.

The existing open->private eviction is kept for immediate CLOSED UX on
the ingesting node; this filter is the cluster-wide backstop.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman merged commit 7dd5b34 into main Jun 9, 2026
16 checks passed
@wesbillman wesbillman deleted the brain/channel-controls branch June 9, 2026 16:01
tlongwell-block pushed a commit that referenced this pull request Jun 10, 2026
* origin/main:
  Fix post-compact handoff context for OpenAI providers (#931)
  chore(release): release version 0.3.15 (#936)
  fix: persona is source of truth at spawn + thread-depth conventions (#930)
  fix: skip avatar reconciliation for legacy agent records (#933)
  feat(desktop): add nest commit identity guidance with human sign-off (#929)
  feat: provider/model selection for personas and runtime-aware env injection (#794)
  fix: reconcile agent profile on startup when relay publish was missed (#921)
  Revamp first-run onboarding (#924)
  Update setup loading screen (#926)
  fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV) (#857)
  Maximize desktop window on launch (#925)
  feat: preview features (experiments settings UI) (#888)
  fix(updater): send no-cache header on update check to avoid stale manifest (#922)
  fix(desktop): refresh channel state after unarchive (#923)
  Add channel visibility & ephemeral TTL controls to manage sidebar (#911)
  ci(release): add Intel macOS (x86_64) DMG as a release target (#748)

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>

# Conflicts:
#	desktop/src/features/sidebar/ui/AppSidebar.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant