Files
hyprcrd/public/plugins/collab-buckets
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +02:00
..

collab-buckets plugin

Collaborative buckets shared between relay tenants. Each bucket has an owner and a set of members; membership changes and metadata updates propagate through the existing relay. Collab buckets are first-class rows in the normal bucket bar — the plugin just owns the sync plus an unread indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.

Phase 1 through 5 shipped. Metadata + membership sync, bucket-bar integration, full card sync (create / edit / delete, LWW conflict resolution with local Conflicts-bucket stashing, encrypted round-trip via the relay's decrypt-reencrypt hop), advisory per-card edit locks with a 🔒 badge on cards another user is currently editing, bucket-ownership transfer through a members & owner modal, and the Phase 5 hardening pass: "Make collaborative…" upgrade path for existing local buckets, undo for local bucket delete/leave, resilient create via a local outbox that queues intents when the relay is unreachable and retries on the next poll, and rate-limiting on all collab endpoints. Remaining nice-to-haves (position propagation, presence, read receipts, inline member picker, owner approval flow) are in TODO.md.


What it does

  • Create a collab bucket from the normal + Bucket button in the bucket bar. The plugin intercepts the button click (via window._collabBucketsCreateDialog) and shows a modal with an optional "collaborative" checkbox. Unchecked → a regular local bucket, same as before. Checked → reveals a member picker listing all other relay tenants, and the submit path goes through /api/collab-buckets instead of the core /api/buckets.
  • See other members' buckets appear automatically in the bucket bar. Every 30 seconds the plugin polls the relay for a full resync; any new or updated bucket the current user is a member of is mirrored into the main buckets table, so the core bar renders it alongside everything else.
  • Collab tabs are visually distinguished: the folder icon is swapped for ph-users-three, there's a subtle left-edge accent stripe, and the right-click menu's rename / color / delete actions route through the plugin endpoints so changes reach the relay and other members.
  • Unread state: brand-new buckets and ones whose updated_at exceeds the local last_seen_at show a red dot top-right of the tab and pulse 3 times on the next render. Clicking the tab triggers mark-seen which clears the dot/pulse immediately and persists to the plugin backend.

Architecture

Two places store bucket state locally:

  1. Main buckets table (core) — gains a collab_uid column so collab buckets are indistinguishable from local buckets in the bucket bar, bucket selects, card-to-bucket joins, etc. collab_uid is unique and NULL for regular local buckets.
  2. Plugin collab_buckets table — holds collab-only metadata (members_json, owner_username, updated_at, last_seen_at) that the main table doesn't need. On every poll the plugin keeps both tables in sync.
┌───── Browser ─────┐                              ┌──── Relay ────┐
│                   │                              │               │
│ bucket-bar (core) │                              │               │
│   ├─ bucket rows  │  POST /api/collab-buckets    │ collab-       │
│   │  incl. collab │  (create via + Bucket modal) │ buckets.php   │
│   │  collab_uid   │ ───────────────────────────▶ │               │
│   │               │                              │ collab_       │
│ collab-buckets.js │  POST /api/collab-buckets    │ buckets       │
│  ▲ observer       │  /poll  (every 30s)          │               │
│  │ MutationObs    │ ◀─────────────────────────── │ collab_       │
│  │ + delegation   │     full resync              │ bucket_       │
│  ▼                │                              │ members       │
│ backend.php       │                              │               │
│  ├─ collab_buckets│                              │               │
│  │   plugin table │                              │               │
│  └─ upserts/      │                              │               │
│     deletes rows  │                              │               │
│     in main       │                              │               │
│     buckets table │                              │               │
└───────────────────┘                              └───────────────┘
  • Relay is source of truth for bucket metadata + membership. The two local tables are mirrors that together add last_seen_at (unread state, user-specific, never synced).
  • Full resync every 30 s. Locally cached buckets that are no longer returned by the relay for this user = we were kicked (or the bucket was deleted); both tables drop them and any cards referencing them fall back to unsorted via ON DELETE SET NULL.
  • LWW per bucket: every patch bumps updated_at on the relay. The local poll only re-saves rows whose updated_at actually changed, otherwise it's a no-op and last_seen_at is untouched so unread state survives unrelated polls.

Files

File Purpose
plugin.json Plugin manifest (auto-enabled unless listed in data/plugins.json)
backend.php Plugin backend: collab_buckets plugin table, relay proxy, full-resync poll, mark-seen, + collabUpsertMainBucket / collabDeleteMainBucket helpers that keep the core buckets table in sync
collab-buckets.js Frontend: polling loop, exposes window._collabBucketsCreateDialog for the core + Bucket button, MutationObserver on #bucket-bar to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for collab_uid buckets
collab-buckets.css Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling
README.md This file
TODO.md Deferred features + Phase 3 roadmap

Core-side touchpoints (not in this directory)

File What changes
src/Database.php Migration adds buckets.collab_uid TEXT + a unique partial index
public/index.php api/buckets GET returns collab_uid (via SELECT *). PATCH only allows position on collab buckets (name/color must go through the plugin so other members see them). DELETE refuses collab buckets with a 400 pointing to /api/collab-buckets/{uid}
public/js/app.js Exposes window.state = state so the plugin's observer can read state.buckets to resolve collab_uid per tab
public/js/modules/buckets.js + Bucket click checks window._collabBucketsCreateDialog before falling back to the simple dialog. Rename / color / delete actions check bucket.collab_uid and route through window._collabBucketsPatch / _collabBucketsDelete when set

Relay-side files (not in this directory, part of the main relay deployment):

File Purpose
relay/inc/bootstrap.php Adds collab_buckets + collab_bucket_members tables (auto-migrated on first boot)
relay/inc/routes/collab-buckets.php POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]
relay/index.php requires the new route file after chat.php

API (local)

All routes live under /api/collab-buckets and are registered via PluginManager::instance()->onApiRoute().

Method Path Purpose
GET /api/collab-buckets List local mirror. Each row has has_unread computed from last_seen_at vs updated_at.
POST /api/collab-buckets {name, color, members[]} — generates UID, pushes to relay, mirrors local. The creator's own last_seen_at = now so the tile isn't unread for them.
PATCH /api/collab-buckets/{uid} {name?, color?, members?} — owner-only, proxied to relay. Local row updates on next poll.
DELETE /api/collab-buckets/{uid} Owner → delete; member → leave. Relay decides based on the authenticated caller.
POST /api/collab-buckets/poll Full resync. Drops stale local rows, upserts changed remote rows, preserves last_seen_at.
POST /api/collab-buckets/mark-seen {uid} — sets last_seen_at = now so the dot / pulse clear.
GET /api/collab-buckets/relay-tenants Proxy to relay /api/tenants for the member-picker.

API (relay)

Exact endpoints the local plugin forwards to. Authentication: the standard X-Relay-User / X-Relay-Key client-API headers that the existing chat and messages routes also use.

Buckets

Method Path Behaviour
POST /api/collab-buckets {uid, name, color, members: [usernames]}. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves.
GET /api/collab-buckets Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as members: [usernames].
PATCH /api/collab-buckets/{uid} Owner-only. Member-list replacement is total — pass the full desired list.
DELETE /api/collab-buckets/{uid} Owner → DELETE of bucket + memberships (with action: deleted). Member → just that one membership row (with action: left); the bucket's updated_at is bumped so the owner sees the change on next poll.

Cards (Phase 3)

Method Path Behaviour
POST /api/collab-cards {bucket_uid, card_uid, payload} — upsert. payload is a HYPRCRD-RELAY-V1 envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via masterDecrypt of their stored secret_encrypted), re-encrypts with the inter-tenant key, stores. Server generates updated_at so all clients agree on the authoritative LWW timestamp. Membership-guarded.
GET /api/collab-cards?since=<ts> Pull cards updated since <ts> (ISO string from a previous response's max_updated_at) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with deleted: true and no payload. Hard-capped to 1000 cards per response.
DELETE /api/collab-cards/{bucket_uid}/{card_uid} Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded.

Schema (local)

CREATE TABLE collab_buckets (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uid TEXT UNIQUE NOT NULL,            -- 16-hex string generated by this client
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT '#38bdf8',
    owner_username TEXT NOT NULL,
    members_json TEXT NOT NULL DEFAULT '[]',
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_seen_at TEXT                    -- NULL = never opened → unread
);

-- Phase 3: per-card sync state. Attached to the core `cards` table via
-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
-- relay; the push phase picks up cards where c.updated_at > last_push_at.
-- `last_remote_at` mirrors the updated_at we last applied from the relay.
CREATE TABLE collab_card_state (
    card_id INTEGER PRIMARY KEY,
    relay_uid TEXT NOT NULL,
    bucket_uid TEXT NOT NULL,
    last_push_at TEXT,
    last_remote_at TEXT,
    FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);

-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
-- cards table; drained in the push phase of the next poll.
CREATE TABLE collab_tombstones (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    relay_uid TEXT NOT NULL,
    bucket_uid TEXT NOT NULL,
    queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

members_json is a JSON array of usernames — flat and cheap to render. Because the relay is the source of truth, we don't need to normalise members into a separate local table.

The plugin also creates a BEFORE-DELETE trigger on cards that inserts a tombstone row whenever a card in a collab bucket is deleted — unless the suppress temp table collab_suppress_outbox has a row, which the plugin sets during the poll's apply phase so remotely-triggered deletes don't re-enqueue.

Schema (relay)

CREATE TABLE collab_buckets (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uid TEXT UNIQUE NOT NULL,
    owner_id INTEGER NOT NULL,
    owner_username TEXT NOT NULL,
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT '#38bdf8',
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE TABLE collab_bucket_members (
    bucket_uid TEXT NOT NULL,
    tenant_id INTEGER NOT NULL,
    username TEXT NOT NULL,
    added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (bucket_uid, tenant_id),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);

-- Phase 3: the actual synced card content. `payload` is the re-encrypted
-- envelope stored at rest under the inter-tenant key. `deleted = 1`
-- marks a tombstone; bootstrap.php prunes them after 7 days.
CREATE TABLE collab_cards (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    bucket_uid TEXT NOT NULL,
    card_uid TEXT NOT NULL,
    author_username TEXT NOT NULL,
    payload TEXT NOT NULL,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted INTEGER NOT NULL DEFAULT 0,
    UNIQUE (bucket_uid, card_uid)
);

Owner deletion cascades via ON DELETE CASCADE on the owner_id FK. Member rows are pruned explicitly on leave (or cascade when a tenant is deleted outright). collab_cards rows belonging to a deleted bucket are orphaned but unreachable — the GET query joins collab_bucket_members and returns nothing for them, so they're effectively gone. Future hardening could add a cascade trigger.

Card sync flow (Phase 3)

Every poll does three things in strict order inside one request:

  1. Drain tombstones. The collab_tombstones outbox table is populated by a BEFORE DELETE trigger on cards — whenever a user deletes a card that sits in a collab bucket, the trigger inserts a row {relay_uid, bucket_uid} into the outbox. The poll pushes each row to DELETE /api/collab-cards/{bucket}/{card} and drops the outbox entry on success. Failed pushes stay queued and retry next poll.

  2. Push dirty cards. One query finds every card in a collab bucket whose updated_at is greater than collab_card_state.last_push_at (or never-pushed). Each is encrypted with the local relay_key and POSTed to /api/collab-cards. On success, last_push_at is stamped with the server's returned updated_at so the next poll skips it. Cards without a relay_uid get one assigned (16 hex chars) and the core cards.relay_uid column is updated so the card has a stable cross-tenant identity.

  3. Pull deltas. GET /api/collab-cards?since=<stored> fetches every card updated or tombstoned since the stored marker (core settings row collab_cards_last_remote_at). For each incoming card:

    • If deleted: true → look up the local card by relay_uid and DELETE it. The BEFORE DELETE trigger is suppressed via the collab_suppress_outbox temp-table flag so it doesn't re-enqueue the tombstone back into the outbox.
    • Otherwise, decrypt the payload, find the local card by relay_uid, and apply LWW (remote.updated_at > local.updated_at → overwrite). New cards are INSERTed with bucket_id resolved via buckets.collab_uid. In both cases collab_card_state.last_push_at and last_remote_at are set to the remote's updated_at so the next push phase doesn't re-send what was just received.

Conflict resolution is strict LWW on updated_at. If two users save the same card within the same second, the second writer wins; the first writer's changes are silently overwritten on their next pull. Phase 4 adds optional edit-locks to surface the conflict before it happens.

Unread / pulse state

has_unread is computed by the plugin backend on every read of /api/collab-buckets:

has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
  • Brand-new bucket → poll inserts the plugin row with last_seen_at = NULL → unread.
  • Metadata / member change → relay bumps updated_at → poll upserts → updated_at > last_seen_at → unread.
  • Click tab → mark-seen sets last_seen_at = now → unread clears on the next render.

The plugin frontend watches #bucket-bar via MutationObserver. After every core re-render, it walks the tabs, looks up each bucket in state.buckets, and applies .bucket-tab-collab plus (if unread) the .pulsing class and an injected .collab-tab-dot element. The pulse animation is CSS-driven (collab-bucket-pulse 1.2s ease-in-out 3), and the JS strips .pulsing after 3.7 s so subsequent re-renders don't retrigger it mid-cycle.

Theming

All chrome hooks into the standard design tokens:

  • --ui-radius-button — tile and button rounding
  • --ui-radius-modal — create/edit modal
  • --ui-radius-input — form inputs
  • --ui-shadow-modal — modal drop shadow
  • --ui-blur-overlay — modal backdrop blur
  • --collab-tile-color (per-tile) — bucket color, mixed into border, text, hover background, and pulse ring

So a collab tile automatically inherits the currently selected theme and the user's design-token choices without any plugin-side theming code.

Why this shape

  • First-class bucket rows, not a separate UI. Collab buckets show up exactly where users already look for buckets. No extra header tiles, no second "collab+" button — the existing + Bucket workflow just gains a "collaborative" checkbox. The mental model stays "this is a bucket", the plugin only layers sync on top.
  • Polling, not push. No long-lived connection, no WebSocket, no daemon. 30-second cadence is perfectly fine for the "someone added a card to our shared bucket" latency envelope and reuses the existing relay HTTP path.
  • Relay as source of truth. The local mirror is almost stateless — only last_seen_at is user-specific and never leaves the device. Wipes and re-syncs are trivial.
  • Owner-only writes. Simpler than ACLs. Owners who want to hand off control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
  • Soft coupling to core. Core exposes two tiny hooks (window._collabBucketsCreateDialog, window.state) plus a single column on the buckets table. Everything else — dialog, decoration, polling, mark-seen — lives in the plugin directory and goes away when the plugin is disabled.

Disabling

Like every other plugin, add "collab-buckets" to the disabled array in data/plugins.json. The local table is left in place (not dropped) so re-enabling doesn't lose unread state.