From d2fa533519a049a08d38304cc9c930e30048c6b5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 1 Apr 2026 12:19:37 -0700 Subject: [PATCH] Improve rTasks drag-drop UX + sync space members on invite claim rTasks: port backlog-md ordinal algorithm (bisection + rebalance), fix column detection via bounding-box hit test, add empty-column drop zones, source column dimming, no-op detection, and optimistic DOM updates (no flash). New bulk-sort-order rebalance endpoint. EncryptID: sync claimed invite members to Automerge doc immediately, redirect to space subdomain after identity claim. Server: add /api/internal/sync-space-member endpoint, fallback member check in WebSocket auth for not-yet-synced invites. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 2 + modules/rtasks/components/folk-tasks-board.ts | 176 +++++++++++++++--- modules/rtasks/mod.ts | 33 ++++ server/index.ts | 45 ++++- src/encryptid/server.ts | 17 +- 5 files changed, 242 insertions(+), 31 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a70b791..1463b96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,8 @@ services: - SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER:-noreply@rmail.online} + - SMTP_PASS=${SMTP_PASS} + - SMTP_FROM=${SMTP_FROM:-rSpace } - SITE_URL=https://rspace.online - RTASKS_REPO_BASE=/repos - SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts index bebbea5..75ab130 100644 --- a/modules/rtasks/components/folk-tasks-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -29,6 +29,7 @@ class FolkTasksBoard extends HTMLElement { private boardView: "board" | "checklist" = "board"; private dragOverStatus: string | null = null; private dragOverIndex = -1; + private dragSourceStatus: string | null = null; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private _offlineUnsubs: (() => void)[] = []; private _history = new ViewHistory<"list" | "board">("list"); @@ -481,16 +482,100 @@ class FolkTasksBoard extends HTMLElement { } catch { this.error = "Failed to move task"; this.render(); } } - private computeSortOrder(targetStatus: string, insertIndex: number): number { + /** Optimistic drop — update local state immediately, persist in background */ + private persistDrop(taskId: string, newStatus: string, sortOrder: number, rebalanceUpdates: { id: string; sort_order: number }[]) { + // Update local tasks array immediately + const task = this.tasks.find(t => t.id === taskId); + if (!task) return; + task.status = newStatus; + task.sort_order = sortOrder; + // Apply rebalance updates to local state + for (const u of rebalanceUpdates) { + const t = this.tasks.find(t => t.id === u.id); + if (t) t.sort_order = u.sort_order; + } + this.render(); + + if (this.isDemo) return; + + // Fire-and-forget API calls + const base = this.getApiBase(); + fetch(`${base}/api/tasks/${taskId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus, sort_order: sortOrder }), + }).catch(() => { this.error = "Failed to save task move"; this.render(); }); + + if (rebalanceUpdates.length > 0) { + fetch(`${base}/api/tasks/bulk-sort-order`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates: rebalanceUpdates }), + }).catch(() => {}); // non-critical + } + } + + // ── Ordinal algorithm (ported from backlog-md) ── + + private static readonly ORDINAL_STEP = 1000; + private static readonly ORDINAL_EPSILON = 1e-6; + + private calculateNewOrdinal( + prev: { sort_order: number } | null, + next: { sort_order: number } | null, + ): { ordinal: number; requiresRebalance: boolean } { + const STEP = FolkTasksBoard.ORDINAL_STEP; + const EPS = FolkTasksBoard.ORDINAL_EPSILON; + const p = prev?.sort_order; + const n = next?.sort_order; + + if (p === undefined && n === undefined) return { ordinal: STEP, requiresRebalance: false }; + if (p === undefined || p === null) { + const candidate = (n ?? STEP) / 2; + return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) || candidate <= 0 || candidate >= (n ?? STEP) - EPS }; + } + if (n === undefined || n === null) { + const candidate = p + STEP; + return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) }; + } + const gap = n - p; + if (gap <= EPS) return { ordinal: p + STEP, requiresRebalance: true }; + const candidate = p + gap / 2; + return { ordinal: candidate, requiresRebalance: candidate <= p + EPS || candidate >= n - EPS }; + } + + private resolveOrdinalConflicts(tasks: any[]): { id: string; sort_order: number }[] { + const STEP = FolkTasksBoard.ORDINAL_STEP; + const updates: { id: string; sort_order: number }[] = []; + let last: number | undefined; + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]; + const assigned = i === 0 ? STEP : (last ?? STEP) + STEP; + if (assigned !== (t.sort_order ?? 0)) updates.push({ id: t.id, sort_order: assigned }); + t.sort_order = assigned; + last = assigned; + } + return updates; + } + + private computeSortOrder(targetStatus: string, insertIndex: number): { sortOrder: number; rebalanceUpdates: { id: string; sort_order: number }[] } { const columnTasks = this.tasks .filter(t => t.status === targetStatus && t.id !== this.dragTaskId) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); - if (columnTasks.length === 0) return 0; - if (insertIndex <= 0) return (columnTasks[0].sort_order ?? 0) - 1000; - if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000; - const before = columnTasks[insertIndex - 1].sort_order ?? 0; - const after = columnTasks[insertIndex].sort_order ?? 0; - return (before + after) / 2; + + const prev = insertIndex > 0 ? columnTasks[insertIndex - 1] : null; + const next = insertIndex < columnTasks.length ? columnTasks[insertIndex] : null; + const { ordinal, requiresRebalance } = this.calculateNewOrdinal(prev, next); + + let rebalanceUpdates: { id: string; sort_order: number }[] = []; + if (requiresRebalance) { + // Insert dragged task at correct position for rebalance + const all = [...columnTasks]; + all.splice(insertIndex, 0, { id: this.dragTaskId, sort_order: ordinal }); + rebalanceUpdates = this.resolveOrdinalConflicts(all); + } + + return { sortOrder: ordinal, rebalanceUpdates }; } private openBoard(slug: string) { @@ -539,6 +624,7 @@ class FolkTasksBoard extends HTMLElement { .col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; } .column.drag-over { background: var(--rs-bg-surface); border-color: var(--rs-primary); } + .column.drag-source { opacity: 0.55; } .task-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px; @@ -590,9 +676,13 @@ class FolkTasksBoard extends HTMLElement { .view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); } /* Drop indicator */ - .drop-indicator { height: 2px; background: var(--rs-primary); border-radius: 1px; margin: 2px 0; animation: pulse-indicator 1.5s ease-in-out infinite; } + .drop-indicator { height: 3px; background: var(--rs-primary); border-radius: 2px; margin: 2px 0; pointer-events: none; animation: pulse-indicator 1.5s ease-in-out infinite; } @keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } } + /* Empty column drop zone */ + .empty-drop-zone { border: 2px dashed #22c55e; border-radius: 8px; padding: 24px 12px; text-align: center; color: #22c55e; font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0; transition: opacity 0.15s; } + .column.drag-over .empty-drop-zone { opacity: 1; } + /* Checklist view */ .checklist { max-width: 720px; } .checklist-group { margin-bottom: 16px; } @@ -746,14 +836,16 @@ class FolkTasksBoard extends HTMLElement { const columnTasks = this.tasks .filter(t => t.status === status) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); + const isDragSource = this.dragTaskId && this.dragSourceStatus === status; return ` -
+
${this.esc(status.replace(/_/g, " "))} ${columnTasks.length}
${status === "TODO" ? this.renderCreateForm() : ""} ${columnTasks.map(t => this.renderTaskCard(t, status)).join("")} + ${columnTasks.length === 0 && status !== "TODO" ? '
Drop task here to change status
' : ''}
`; }).join("")} @@ -990,6 +1082,9 @@ class FolkTasksBoard extends HTMLElement { if (pe.button !== 0) return; if (el.getAttribute("draggable") === "false") return; this.dragTaskId = el.dataset.taskId || null; + // Track source column for dimming + const task = this.tasks.find(t => t.id === this.dragTaskId); + this.dragSourceStatus = task?.status || null; el.classList.add("dragging"); el.setPointerCapture(pe.pointerId); el.style.touchAction = "none"; @@ -999,14 +1094,30 @@ class FolkTasksBoard extends HTMLElement { const pe = e as PointerEvent; pe.preventDefault(); // Clear previous state - this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); + this.shadow.querySelectorAll(".column[data-status]").forEach(c => { + (c as HTMLElement).classList.remove("drag-over"); + // Re-apply drag-source class since we're clearing + if (this.dragSourceStatus && (c as HTMLElement).dataset.status === this.dragSourceStatus) { + (c as HTMLElement).classList.add("drag-source"); + } + }); this.shadow.querySelector('.drop-indicator')?.remove(); - // Find target column - const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null; - const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null; + // Find target column via bounding-box hit test (fixes Shadow DOM elementFromPoint issues) + let targetCol: HTMLElement | null = null; + const columns = this.shadow.querySelectorAll(".column[data-status]"); + for (const col of columns) { + const rect = col.getBoundingClientRect(); + if (pe.clientX >= rect.left && pe.clientX <= rect.right && pe.clientY >= rect.top && pe.clientY <= rect.bottom) { + targetCol = col as HTMLElement; + break; + } + } if (targetCol) { - targetCol.classList.add("drag-over"); - // Calculate insert position + // Don't highlight source column green — it's already dimmed + if (targetCol.dataset.status !== this.dragSourceStatus) { + targetCol.classList.add("drag-over"); + } + // Calculate insert position among non-dragging cards const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)')); let insertIndex = cards.length; for (let i = 0; i < cards.length; i++) { @@ -1020,9 +1131,11 @@ class FolkTasksBoard extends HTMLElement { indicator.className = 'drop-indicator'; if (insertIndex < cards.length) { cards[insertIndex].before(indicator); - } else { - targetCol.appendChild(indicator); + } else if (cards.length > 0) { + // After last card + cards[cards.length - 1].after(indicator); } + // For empty columns the empty-drop-zone CSS handles the visual } else { this.dragOverStatus = null; this.dragOverIndex = -1; @@ -1032,29 +1145,44 @@ class FolkTasksBoard extends HTMLElement { if (!this.dragTaskId) return; el.classList.remove("dragging"); el.style.touchAction = ""; - this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); + this.shadow.querySelectorAll(".column[data-status]").forEach(c => { + (c as HTMLElement).classList.remove("drag-over", "drag-source"); + }); this.shadow.querySelector('.drop-indicator')?.remove(); if (this.dragOverStatus && this.dragOverIndex >= 0) { const task = this.tasks.find(t => t.id === this.dragTaskId); - const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex); if (task) { - if (task.status === this.dragOverStatus) { - this.updateTask(this.dragTaskId!, { sort_order: sortOrder }); - } else { - this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder); + // No-op detection: same column, same position → skip + const colTasks = this.tasks + .filter(t => t.status === this.dragOverStatus && t.id !== this.dragTaskId) + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); + const currentIdx = this.tasks + .filter(t => t.status === task.status) + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) + .findIndex(t => t.id === task.id); + const sameColumn = task.status === this.dragOverStatus; + const samePosition = sameColumn && (this.dragOverIndex === currentIdx || this.dragOverIndex === currentIdx + 1); + + if (!samePosition) { + const { sortOrder, rebalanceUpdates } = this.computeSortOrder(this.dragOverStatus!, this.dragOverIndex); + this.persistDrop(this.dragTaskId!, this.dragOverStatus!, sortOrder, rebalanceUpdates); } } } this.dragTaskId = null; + this.dragSourceStatus = null; this.dragOverStatus = null; this.dragOverIndex = -1; }); el.addEventListener("pointercancel", () => { el.classList.remove("dragging"); el.style.touchAction = ""; - this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); + this.shadow.querySelectorAll(".column[data-status]").forEach(c => { + (c as HTMLElement).classList.remove("drag-over", "drag-source"); + }); this.shadow.querySelector('.drop-indicator')?.remove(); this.dragTaskId = null; + this.dragSourceStatus = null; this.dragOverStatus = null; this.dragOverIndex = -1; }); diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index ca1b317..2404de4 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -373,6 +373,39 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { }, 201); }); +// PATCH /api/tasks/bulk-sort-order — rebalance sort orders for multiple tasks +// Registered BEFORE /api/tasks/:id to avoid param capture +routes.patch("/api/tasks/bulk-sort-order", async (c) => { + const body = await c.req.json(); + const { updates } = body; + if (!Array.isArray(updates) || updates.length === 0) { + return c.json({ error: "updates array required" }, 400); + } + + // Group updates by board doc + const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:')); + let applied = 0; + + for (const { id, sort_order } of updates) { + if (!id || sort_order === undefined) continue; + for (const docId of allBoardIds) { + const doc = _syncServer!.getDoc(docId); + if (doc && doc.tasks[id]) { + _syncServer!.changeDoc(docId, `Rebalance sort order ${id}`, (d) => { + if (d.tasks[id]) { + d.tasks[id].sortOrder = sort_order; + d.tasks[id].updatedAt = Date.now(); + } + }); + applied++; + break; + } + } + } + + return c.json({ ok: true, applied }); +}); + // PATCH /api/tasks/:id — update task (status change, assignment, etc.) routes.patch("/api/tasks/:id", async (c) => { // Optional auth — track who updated diff --git a/server/index.ts b/server/index.ts index 9ee6844..10cbe9f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -29,6 +29,7 @@ import { listCommunities, deleteCommunity, updateSpaceMeta, + setMember, } from "./community-store"; import type { NestPermissions, SpaceRefFilter } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; @@ -586,6 +587,23 @@ app.post("/api/internal/provision", async (c) => { return c.json({ status: "created", slug: space }, 201); }); +// POST /api/internal/sync-space-member — called by EncryptID after identity invite claim +// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc +app.post("/api/internal/sync-space-member", async (c) => { + const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>(); + if (!body.spaceSlug || !body.userDid || !body.role) { + return c.json({ error: "spaceSlug, userDid, and role are required" }, 400); + } + try { + await loadCommunity(body.spaceSlug); + setMember(body.spaceSlug, body.userDid, body.role as any, body.username); + return c.json({ ok: true }); + } catch (err: any) { + console.error("sync-space-member error:", err.message); + return c.json({ error: "Failed to sync member" }, 500); + } +}); + // POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed app.post("/api/internal/mint-crdt", async (c) => { const internalKey = c.req.header("X-Internal-Key"); @@ -3081,7 +3099,19 @@ const server = Bun.serve({ if (vis === "private" && claims && spaceData) { const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid; - const isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid]; + let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid]; + // Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge) + if (!isOwner && !isMember) { + try { + const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`); + if (memberRes.ok) { + const memberData = await memberRes.json() as { role: string; userDID: string }; + // Sync to Automerge so future connections don't need the fallback + setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username); + isMember = { role: memberData.role }; + } + } catch {} + } if (!isOwner && !isMember) { return new Response("You don't have access to this space", { status: 403 }); } @@ -3091,12 +3121,15 @@ const server = Bun.serve({ } } - // Resolve the caller's space role - if (spaceData) { - if (claims && spaceData.meta.ownerDID === claims.sub) { + // Resolve the caller's space role (re-read doc in case setMember was called during fallback sync) + const spaceDataFresh = getDocumentData(communitySlug); + if (spaceDataFresh || spaceData) { + const sd = spaceDataFresh || spaceData; + const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined; + if (claims && sd!.meta.ownerDID === claims.sub) { spaceRole = 'admin'; - } else if (claims && spaceData.members?.[claims.sub]) { - spaceRole = spaceData.members[claims.sub].role; + } else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) { + spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role; } else { // Non-member defaults by visibility const vis = spaceConfig?.visibility; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index c0394df..48c8465 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -5592,6 +5592,18 @@ app.post('/api/invites/identity/:token/claim', async (c) => { const inviterUser = await getUserById(invite.invitedByUserId); const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`; await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid); + // Sync to rSpace's Automerge doc so the member has immediate access + const rspaceUrl = process.env.RSPACE_INTERNAL_URL || 'http://rspace-online:3000'; + fetch(`${rspaceUrl}/api/internal/sync-space-member`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + spaceSlug: invite.spaceSlug, + userDid: did, + role: invite.spaceRole, + username: inviteeUser?.username, + }), + }).catch((err) => console.error('EncryptID: Failed to sync space member to Automerge:', err.message)); } // Auto-add email to OIDC client allowlist if this is a client invite @@ -5909,9 +5921,12 @@ function joinPage(token: string): string { // Success! document.getElementById('registerForm').style.display = 'none'; statusEl.style.display = 'none'; + const destUrl = claimData.spaceSlug + ? 'https://' + claimData.spaceSlug + '.rspace.online' + : 'https://rspace.online'; successEl.innerHTML = 'Welcome to rSpace!
Your identity has been created and your passkey is set up.' + (claimData.spaceSlug ? '
You\\'ve been added to ' + claimData.spaceSlug + '.' : '') + - '

Go to rSpace →'; + '

Go to ' + (claimData.spaceSlug || 'rSpace') + ' →'; successEl.style.display = 'block'; } catch (err) {