- sync: the post-sync toast lumped relay pulls into the CalDAV stats
and always labelled the banner "CalDAV". Split into two separate
toasts (CalDAV cloud-arrow + Relay broadcast icon), each only shown
when its numbers are non-zero. Banners now stack vertically so both
can appear together on a sync that pulled on both sides.
- relay UI: the session chat view was 1:1-only. Group rooms (which
the client API already supports) now surface as first-class threads
in the sidebar — sorted chronologically with 1:1 peers, tagged
"ROOM", with a users-three icon. Sending posts to `room_uid`
instead of `recipient`; history shows the sender name above each
incoming message so it's clear who's talking in the room.
Relay backend: `/api/chat/threads` returns rooms alongside 1:1
threads; `/api/chat/history` accepts `?room_uid=`; `/api/chat/send`
accepts `room_uid` in the body.
- tests: bump "replace all updates card bodies" poll budget from 4s
to 10s and poll both updated cards. Flaky on cold runs because a
fresh PHP worker + SQLite WAL warm-up plus two sequential PATCH
calls can just barely exceed 4 seconds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-card opt-in push to the relay for distraction-free mobile reading.
Not tag-based: a real cards.read_push column so the reading state
stays independent of whatever tags the user organises by.
hyprcrd
- cards.read_push column (0/1, default 0) via Database migration.
- POST /api/cards + PATCH /api/cards/{id} accept read_push; save hook
posts the card (title+body, encrypted) and referenced images
to the relay's /api/read endpoint. First inline image is flagged
as the hero. Toggle-off / DELETE wipes the remote copy.
- Detail-bar toggle button ("Read" → "On relay") with filled book
icon + inverted accent background when active; outline book icon
in the bottom meta row of the board card when read_push=1.
relay
- read_items + read_attachments tables, encrypted under the
inter-tenant key like messages (dual-format envelope).
- Client API: POST /api/read, DELETE /api/read/{uid}, POST
/api/read-attachments. Session API: GET /api/read (list),
GET /api/read/{uid} (single decrypted), GET /api/read-attachments/
{uid}/{filename} (serves decrypted image bytes).
- Payload size caps raised to 15MB (attachments) / 2MB (items).
- New nav tab + view: list with thumbnails, click opens a zen reader
overlay. Reader caps article width at 680px, bumps type to 17px/1.7,
renders a minimal markdown subset (headings, lists, code fences,
blockquotes, links, inline images), rewrites uploads/ references
to the session-authed image endpoint. Escape closes the reader.
- Mobile-first: wider touch targets, tighter padding at ≤640px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- relay: raise /api/collab-attachments + /api/attachments/upload POST
size limit from 256KB to 15MB, matching /api/attachments. Previous
limit silently dropped any collab image >~100KB as 413 (envelope
overhead pushes even modest JPEGs past the cap), leaving puller
clients with broken <img src="uploads/..."> references.
- oumta-dashboard: centre overview tiles, bump value font to 56px,
introduce .oumta-fail alarm state that paints the full tile red with
white text. Toggled from JS for cluster state=down, workloads down
>0, and open incidents >0. Fail colour uses --t-danger so it stays
in sync with the "unavailable" workload labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so
existing user secrets, stored messages, and inter-tenant key keep
working. Old clients and relays lose cross-version chat until both
sides are upgraded.
Relay
- Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived
per-ciphertext salts; keep CBC-V1 fallback on decrypt only.
- On-bootstrap migration re-envelopes tenant secret_encrypted and the
inter-tenant key to GCM-V2 (idempotent).
- CORS: stop reflecting arbitrary Origin; allow own origin + optional
RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials.
- Generic error messages replace "no such user: <name>" enumeration.
- Exception messages no longer echoed back; logged server-side.
- Collab-lock max hold capped at 10min so a member can't grief-refresh
forever.
- Attachment filenames validated against hex.<ext> pattern.
- Passwords min length 10; /api/account/password rate-limited.
App / Electron client
- Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read).
- Chat + collab-buckets plugins delegate to the shared helpers.
- Poll + push + attachments migrated to the dual-format path.
- /api/relay/settings GET returns has_key boolean; secret never echoed.
- Setup endpoint now CSRF-checked.
- SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks
favicon fetch also blocks private/loopback/link-local.
- Login / password-change rate limiter moved to DB-backed IP keying.
- Passwords min length 10 throughout.
- Zip import enforces per-file (25MB) and total (500MB) caps, extension
allowlist, no-overwrite.
- Verbose relay-push debug gated behind APP_DEBUG env.
Version bumped to 2.0.0 (protocol-breaking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the doubled lists: the relay DB had stale rows from
previous activations that were never deleted. The composite PK
prevented same-uid duplicates but not leftover rows from cards that
were deactivated without a successful DELETE.
Fixes:
- Client tick sends DELETE { keep: [active uids] } after every
push cycle. The relay drops all rows for this tenant that aren't
in the keep list — cleans up migration leftovers, failed deletes,
old single-list sentinels, everything in one shot.
- Relay DELETE endpoint accepts { keep: [...] } for bulk cleanup
alongside the existing { card_uid } single-delete.
- Relay layout.php adds cache-busting query strings on style.css
and app.js via filemtime() so phone browsers pick up new code
on deploy instead of serving stale cached versions.
- showView persists the active view name to localStorage; after
login the relay restores it instead of always landing on compose.
- Shopping-list-box border/background removed — items have their
own chrome, the box is just a flex grouper now.
- View title is always "Shopping"; each list box keeps its own
header with the card title. No more doubled name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
.shopping-items li was the old single-list item rule. After the
multi-list refactor, the direct children of .shopping-items are
<li class="shopping-list-box"> wrappers, not individual items.
The old rule gave the box the same background + border + padding +
tap-target as the .shopping-item divs inside it — making each list
look like it appeared twice.
Drop the generic li rule entirely. Items are styled via
.shopping-item, boxes via .shopping-list-box, empty state via
.shopping-items .empty. Also dedup by card_uid in loadShopping JS
as a safety net.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The old single-list clearRemote wrote a dummy row with card_uid
'__inactive__' instead of deleting. After the multi-list migration
that row still exists alongside real lists, causing every list to
appear twice on the phone — once as "all done" (the empty sentinel)
and once with actual items. Filter it out in shoppingLoadAll and
delete it on bootstrap so it doesn't linger.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#compose-sidebar only had its flex-column + gap:16px declaration
inside the desktop media query, so on mobile the inbox/shared/sent
cards stacked as plain blocks with no deliberate spacing. Move the
rule to base CSS so the rhythm between inbox → shared → sent
matches the compose → inbox gap regardless of viewport width.
Desktop re-declares it next to the grid layout for clarity; the
duplication is harmless.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure the shopping-list feature end to end:
- New public/plugins/shopping/ plugin with backend.php (proxy
routes registered via PluginManager::onApiRoute), shopping.js
(self-contained; injects the header Shopping button, owns the
picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
placeholder css file. Core no longer knows about shopping —
index.php loses the static button and the proxy block, app.js
loses the initShopping wiring, the old js/modules/shopping.js
is gone.
- Phone view moves out of a standalone /shopping page and into
the relay UI as a regular `view-shopping` behind a nav-burger
entry (relay/ui/layout.php + app.js + style.css). Same session
auth as compose/chat/account — no separate login. Auto-refresh
every 4 s while the view is active; no manual refresh button.
Headings "# h1 … ###### h6" render between items for grouping.
Orphan section headings (no open items under them) are hidden.
- Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
timezones and silently blocked pushes after a pull). Both sides
diff the (uid, title, body) triple instead — timezone-agnostic
and authoritative. Client ticks push-before-pull so fresh local
edits land on the relay before a stale pull can overwrite them.
Relay drops its "server newer" guard and stamps server times
with gmdate so anything left comparing to SQLite is UTC.
- Client also ticks on cards.reloaded, not just sync.tick, so
any edit path (editor save, checkbox toggle, context menu,
undo) pushes the update immediately.
- Tests: updated for the plugin (still uses the same selectors
because the plugin injects the same ids), plus a plugin-
registration check and "(no active list)" option assertion.
- Help section copy updated — references the Shopping header
button and the shopping tab in the relay UI rather than the
gone /shopping URL.
Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Picker dialog, hamburger menu entry, phone page and help section
still had German labels from the original request ("Einkaufsliste",
"keine aktive Liste", etc.). The rest of the app's UI is English,
so align everything here: menu entry is "Shopping list", dialog +
phone page copy translated, help references match. Trigger tags
stay as-is — both #einkauf and #shopping keep working so users with
either convention aren't disrupted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.
- Relay: shopping_state table (one row per tenant), POST/GET
/api/shopping for content push/pull, POST /api/shopping/check
for phone toggles, GET /shopping for the phone UI.
- Client: hamburger menu in the header with an "Einkaufsliste"
entry that opens a picker listing all #einkauf cards with a
radio for the active one. Timestamp cursors in localStorage
keep push/pull idempotent.
- Phone can only toggle, not edit text — edit stays in hyprcrd,
which makes the merge trivial (last-writer wins by timestamp).
Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Group inserts were using recipient_id=0 as a "no recipient" sentinel,
which fails the tenants FK (foreign_keys is ON) and 500s on every
group send. Use the sender's own id as the placeholder instead;
group GET queries already key off room_uid so there's no self-message
artefact from the value.
Also exclude group rows from /api/chat/sent so the client's
last_sent_relay_id cursor doesn't stall on group sends that never
reach seen=1 (group receipts aren't tracked in v1).
Client-side: surface a clearer error when a room create hits a 404
on the relay — almost always means the relay is running pre-group
code and needs a restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds named group threads (chat_rooms + chat_room_members), mirroring
the collab-buckets membership pattern. Group messages share the
chat_messages table via a new nullable room_uid column; recipient_id
is left 0 for group rows and fan-out happens via the member-join at
GET time instead of duplicating per recipient.
Chat retention flips from "drop everything after 7 days" to
persistent-by-default: a new `persistent` flag on chat_messages marks
long-term rows (default 1 — all group messages plus non-disposable
1:1). The 7-day cleanup only targets persistent=0 rows so the sender
can still poll final receipt state before they're pruned.
New endpoints:
POST/GET/PATCH/DELETE /api/chat/rooms[/{uid}] — room CRUD
GET /api/chat/backfill — scrollback / cold-start history fetch
POST /api/chat — accepts recipient OR room_uid + disposable flag
GET /api/chat — returns 1:1 to caller AND rooms caller is in
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay UI already shows a tenant's inbox + sent mails. Add a parallel
view for collaborative bucket cards the logged-in tenant is a member
of, so activity on shared boards is visible without needing the hyprcrd
client open.
New `GET /api/collab-inbox` endpoint (session-auth only) decrypts
payloads with the inter-tenant key and returns title + preview + author
+ bucket metadata. UI restructures the compose view into editor + right
sidebar stacking inbox → shared → sent, and polls all three on the
existing 15s tick.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- replace chat-bot sync notifications with a dedicated relay_sync_events
queue: POST/GET /api/sync-events, drained by /api/relay/poll and
surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed,
bot=1 rows purged on bootstrap, orange bot rendering dropped from
relay UI. Chat thread stays clean.
- collab pull: fetch attachments BEFORE applying the body so the moment
state.cards reflects the new reference the file is on disk. Fixes the
"preview shows broken/missing image, editor shows it" race where the
browser cached the 404 between attachment landing and user re-render.
- detail stale banner now compares title/body/color/tags in addition to
updated_at — robust against second-granularity timestamp collisions
and any other case where updated_at doesn't tick but content differs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- per-card unread dot on board (collab-buckets): backend tracks
last_seen_at per card, poll exposes unread_cards, frontend decorates
matching board cards; cleared on any open path via detail.opened hook
- detail.opened + cards.reloaded hooks on the hyprcrd event bus
- stale banner in detail view with manual Reload when the currently
viewed card got updated by a peer — no content swap under the user
- collab image sync: push attachments BEFORE card (race fix) + pull-side
self-heal scans every collab card for missing uploads each poll
- relay user deletion now purges chat_messages sent by deleted user so
ghost threads stop appearing for their peers
- calendar header nav buttons styled as square bordered buttons that
honor --ui-radius-button; title bar widened to 280px so font variants
don't wrap
- .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Collab buckets sync attachments alongside cards (images/PDFs/audio).
New /api/collab-attachments endpoint on relay, encrypted at rest,
bucket-scoped for members.
- CalDAV multi-day events via **Bis:** body field. Calendar renders
event on every day in range with day counter (1/6, 2/6, ...).
- Calendar preserves month across close/reopen (only jumps to today
on first open).
- Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] —
dimmed brackets, fixed width so nothing wiggles.
- Editor: scratchpad uses custom confirm, autobackup-draft dialog
replaced with custom styled dialog.
- Scanlines theme feature removed (performance issue).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Scratchpad: card-scoped private notes in editor (toggle vs preview),
smaller font, cleared via button, stored in cards.scratchpad
- Zen mode: ESC exits zen before closing detail, Z toggles zen
- Chat: always show absolute time (hh:mm, yesterday, or date+time)
- Backspace/Delete also deletes focused card (not just selected)
- Selected + focused cards use brand accent (not accent2)
- Tooltip: guard against non-element targets
- Collab sync: skip poll while editor is open, 2s push debounce
- Rrule test: only check geburtstag tag, not title format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Inbox poll continues fetching but skips DOM updates when user is typing,
deleting, or confirming. Quiet mode diffs message IDs to avoid
unnecessary re-renders. Focus/blur tracking on all inputs auto-guards.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Electron desktop app section, document all 8 plugins, update
features list (assets, super-buckets, find/replace, arrow nav, PDF
upload), fix relay docs (chat shipped, not future), add missing API
endpoints, correct electron data storage paths and php-bin fetch info.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The relay's nav row used to flatten on mobile by hiding admin / user
identity / compose buttons outright, leaving only "account" + "logout".
Admin was completely unreachable from a phone. Replaces that with a
proper burger menu and exposes every nav target.
* layout.php: new <button id="nav-burger"> with a ph-list icon between
the spacer and #nav-buttons. Inline `style="display:flex"` removed
from #nav-buttons — display is now CSS-driven, so the burger
popover and the desktop row don't fight each other.
* app.js showView(): the mobile-block on `name === 'admin'` is gone.
Burger button visibility is now mirrored against the same
setup/login/change-password gating that hides #nav-buttons. Picking
any view also clears `.menu-open` so the popover collapses behind
the freshly-shown view.
* app.js toggleNavMenu(): exposed on window for the inline onclick.
Document-level click outside closes the menu.
* style.css: default `#nav-buttons:not(.hidden)` becomes flex (same
semantics as the old inline). #nav-burger is `display:none` on
desktop, `inline-flex` on mobile. Mobile media query drops the old
display:none rules for #nav-admin / #view-admin / #nav-compose,
keeps #nav-user hidden (redundant with the menu), and adds a
`.menu-open` rule that turns #nav-buttons into a popover anchored
to the header (position:absolute, right:12px, surface bg, border,
shadow, z-index 50). Buttons inside the popover go full-width
left-aligned. header { position:relative } so the absolute
positioning has an anchor.
* style.css: also #view-admin .section gets a simple display:block
override on mobile so the desktop grid-area layout doesn't break
the page when admin is opened from a phone.
header .user .badge sizing (carried over from the previous turn): the
admin badge in the relay header now matches the .nav-btn box-model
exactly (5px 10px padding, 9px font, 1px border, 3px radius, line-
height:1, pixel font), so it sits flush with the nav buttons instead
of being a tiny pill in the middle of the row.
chore: bump version to 0.9.8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Read receipts on chat messages — single check for delivered (recipient
polled), double check for seen (recipient opened the thread). Pending
state shows a clock until the relay acks the post.
Relay (relay/):
* bootstrap.php: ALTER TABLE chat_messages ADD COLUMN seen + a
(sender_id, id) index for the sender-side state poll. Migration is
idempotent via PRAGMA table_info.
* routes/chat.php: two new routes
- GET /api/chat/sent?since=N — returns the receipt state (id,
recipient_id, fetched, seen, created_at) for messages the caller
sent. No body — sender already has the plaintext locally.
- POST /api/chat/seen { peer } — marks every message FROM peer TO
caller as seen=1. Idempotent. Frontend calls this when opening
a thread; sender's next /sent poll picks the new state up.
Plugin backend (public/plugins/chat/backend.php):
* schema migration: chat_messages gets `delivered` and `seen` columns
(idempotent), only meaningful for direction='out'.
* /api/chat/send: stores the relay's autoincrement id as `relay_id`
on the local outgoing row so the receipt poll can match against it.
* /api/chat/poll: after the normal incoming pull, also calls
GET /api/chat/sent?since=last_sent_relay_id and updates local
outgoing rows with delivered/seen via UPDATE WHERE relay_id. The
cursor (last_sent_relay_id state value) only advances past rows
whose state is final (seen=1); rows still in flight stay re-pollable.
Response now carries `receipts: <count>` so the frontend can refresh
the open thread when state changes without new incoming.
* /api/chat/read: after the local is_read=1 update, fires a POST to
the relay's /api/chat/seen so the sender gets the double-check.
Silent on failure — the user already saw the messages locally.
* /api/chat/messages: SELECT now includes delivered, seen.
Plugin frontend (public/plugins/chat/chat.js):
* renderThread() appends a receipt icon to outgoing bubbles:
- seen=1 → ph-checks (accent, double check)
- delivered=1, seen=0 → ph-check (muted, single check)
- both 0 → ph-clock (dim, pending)
Incoming messages get nothing.
* pollOnce() refreshes the open thread when receipts > 0 even with
no new incoming, so the icons update in real time while the user
is looking at the thread.
CSS (public/plugins/chat/chat.css):
* .chat-bubble-time becomes flex with gap so timestamp + receipt
icon sit on one line.
* .chat-receipt.pending / .delivered / .seen color tiers.
board: persist active bucket across reloads
* state.js: state.activeBucket initializes from
localStorage.getItem('hyprcrd-active-bucket') || 'all'. New
setActiveBucket(value) helper writes through to localStorage; new
ensureActiveBucketValid() helper falls back to 'all' if the
persisted id no longer exists in state.buckets.
* buckets.js: loadBuckets() calls ensureActiveBucketValid() right
after the API fetch and before renderBar(). All four production
assignments (delete-keep, delete-all, popover entry click, regular
tab click) replaced with setActiveBucket() so the persisted value
stays in sync. Tests still mutate state.activeBucket directly so
they don't touch localStorage.
chore: bump version to 0.9.6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous lock implementation was advisory only — the relay's lock
table PK was (bucket_uid, card_uid, holder_id), so multiple users could
"hold" the same card simultaneously and the plugin only used the locks
to draw a small badge. Concurrent writers raced through LWW, and an
unsaved editor on User A could be silently overwritten when User B
saved (the symptom: cards appearing to "disappear" from User A's view
once their version was clobbered by an LWW pull).
Now an exclusive, server-enforced lock:
* relay schema: collab_card_locks PK is (bucket_uid, card_uid). One
lock per card. Migration drops the old 3-column-PK table on the
next bootstrap (locks are 60s ephemeral, no data to preserve).
* /api/collab-locks POST: returns 409 + holder info when another
tenant already holds it. Same holder refreshes the TTL.
* /api/collab-cards POST/DELETE: refuse with 423 + locked_by when a
foreign tenant holds the lock. The plugin's local edit is preserved
— the push just stays queued and retries on the next poll until
the lock expires or is released. Tombstones are gated the same way
so a card can't be deleted under the holder's editor.
* Plugin push phase: tracks blocked count + holder names, surfaces
them in the poll response so the frontend can toast the user.
Frontend lock-on-first-keystroke flow:
* The lock is no longer acquired on editor-open — opening for read
doesn't lock anything. On the FIRST input event in #inp-title or
#editor-body the plugin tries to acquire. On success, heartbeat
every 30 s; on 409, immediately enter editor takeover.
* Takeover mode: title + body inputs disabled, all three save
buttons get a capture-phase click blocker (stopImmediatePropagation
to defeat the editor's own listener), a window-level capture-phase
keydown blocker swallows Ctrl/Cmd+S (app.js binds save directly to
the shortcut, bypassing the buttons), and a banner inside the
modal explains the situation. The user can still copy their
unsaved text out before closing.
* If a foreign lock arrives via the regular poll while the editor is
already open on that card, the same takeover engages mid-session.
* Opening the editor on a card whose lock is *already* known to be
foreign starts in takeover mode without arming the input listeners
at all.
* Self-detection: poll response now carries `self` (the relay
username), cached as window._collabSelf, so we don't take ourselves
over and the board decoration ignores our own lock.
Visual:
* Board cards held by a foreign lock now get a frosted overlay +
centered lock icon + holder name (replaces the small corner badge).
* Editor takeover banner uses the danger accent. Save buttons get a
.collab-save-blocked dim style.
* New `.collab-lock-toast` variant of the existing conflict toast,
used for both "your push is queued because X is editing" and
"this card just got locked by X" notifications.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.
Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
color, which caused the test runner's auto-restore to wipe custom
bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
collab_cards, collab_card_locks tables and prunes tombstones / expired
locks on every request
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>