collab per-card unread, detail stale banner, image sync race fix
- 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>
This commit is contained in:
1 parent
39e94b8015
commit
26f95ba079
10 files changed
+324
-35
No files matched your search
@@ -72,9 +72,83 @@ export function openDetail(id, options = {}) {
|
||||
|
||||
renderSidebar(card);
|
||||
showContent(card);
|
||||
dismissStaleBanner();
|
||||
detail.classList.add('open');
|
||||
|
||||
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
||||
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
||||
}
|
||||
|
||||
// ── Remote-update detection ──
|
||||
//
|
||||
// When a foreign edit arrives for the card the user is currently viewing,
|
||||
// the pull replaces state.cards but the open detail view keeps rendering
|
||||
// the stale object it captured on open. Instead of force-re-rendering
|
||||
// under the user's finger (jarring when they're mid-read), surface a
|
||||
// banner with a manual Reload button.
|
||||
|
||||
let staleBannerEl = null;
|
||||
|
||||
function dismissStaleBanner() {
|
||||
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
||||
}
|
||||
|
||||
function showStaleBanner(fresh) {
|
||||
dismissStaleBanner();
|
||||
const host = detail.querySelector('#detail-controls')?.parentElement;
|
||||
if (!host) return;
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'detail-stale-banner';
|
||||
bar.innerHTML = `
|
||||
<i class="ph ph-info"></i>
|
||||
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
||||
<button class="detail-stale-reload">
|
||||
<i class="ph ph-arrows-clockwise text-xs"></i>
|
||||
<span>Reload</span>
|
||||
</button>
|
||||
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
||||
`;
|
||||
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
||||
staleBannerEl = bar;
|
||||
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
||||
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
||||
if (current) {
|
||||
state.viewingCard = current;
|
||||
renderSidebar(current);
|
||||
showContent(current);
|
||||
}
|
||||
dismissStaleBanner();
|
||||
});
|
||||
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
const t = new Date(iso).getTime();
|
||||
if (!isFinite(t)) return '';
|
||||
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// After every full card reload, check whether the card we're viewing is
|
||||
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||
// detail view closed in the meantime.
|
||||
window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
|
||||
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||
dismissStaleBanner();
|
||||
return;
|
||||
}
|
||||
const viewingId = state.viewingCard.id;
|
||||
const fresh = state.cards.find(c => c.id == viewingId);
|
||||
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||
const old = state.viewingCard;
|
||||
if ((fresh.updated_at || '') > (old.updated_at || '')) {
|
||||
showStaleBanner(fresh);
|
||||
}
|
||||
});
|
||||
|
||||
function clearMorphGhost() {
|
||||
if (morphTimer) {
|
||||
clearTimeout(morphTimer);
|
||||
@@ -370,6 +444,7 @@ export function closeDetail(options = {}) {
|
||||
closeImageFullscreen();
|
||||
|
||||
detail.classList.remove('open', 'zen');
|
||||
dismissStaleBanner();
|
||||
removeZenHandles();
|
||||
document.documentElement.classList.remove('detail-zen-open');
|
||||
document.body.classList.remove('detail-zen-open');
|
||||
|
||||
Reference in new issue
Block a user