From 5c321199460c3271b5f998be7e3b6dfbf83d8127 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:28:26 -0400 Subject: [PATCH 1/7] feat(rsocials): auto-push scheduled posts to Postiz + reconcile published state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-post "Schedule to Postiz" button on Post nodes (rSpace → Postiz), a 60s server sweep that auto-pushes Scheduled posts within a 10min lead window, and a Postiz → rSpace reconcile poll that flips postizStatus to 'published' (or 'failed') and records publishedAt + releaseURL. - schemas: PostNodeData gains postizPostId, postizIntegrationId, postizStatus, postizError, postizSentAt, postizCheckedAt, postizReleaseURL, publishedAt. - postiz-client: listPosts(startDate, endDate) for reconciliation. - mod.ts: sendCampaignNodeToPostiz() helper, POST /api/campaign/flows/ :flowId/nodes/:nodeId/send-postiz, postizSweep() + postizReconcile() wired into onInit via startPostizScheduler. - folk-campaign-planner: button + status badge in Post inspector, timeline/table dot colors reflect postizStatus (queued=purple, published=green, failed=red). Timeline bucket stays by scheduledAt. - campaign-planner.css: postiz state pill styles. - Bump campaign-planner JS/CSS to ?v=2 to bust CF cache. Mock-Postiz smoke against the client lib passes 20/20 assertions. Live Postiz round-trip pending deploy to Netcup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rsocials/components/campaign-planner.css | 17 ++ .../components/folk-campaign-planner.ts | 78 +++++- modules/rsocials/lib/postiz-client.ts | 28 ++ modules/rsocials/mod.ts | 252 +++++++++++++++++- modules/rsocials/schemas.ts | 8 + 5 files changed, 376 insertions(+), 7 deletions(-) diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index a684c1b7..6c838bbd 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -308,6 +308,23 @@ folk-campaign-planner { min-height: 60px; } +.cp-postiz-state { + padding: 6px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + margin-top: 4px; + line-height: 1.3; +} +.cp-postiz-state--queued { background: rgba(249, 115, 22, 0.12); color: #f97316; border: 1px solid rgba(249, 115, 22, 0.3); } +.cp-postiz-state--published { background: rgba(34, 197, 94, 0.12); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); } +.cp-postiz-state--failed { background: rgba(239, 68, 68, 0.12); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); } + +.cp-btn--postiz[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + .cp-icp-toolbar { padding: 8px 12px; border-top: 1px solid var(--rs-border, #2d2d44); diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 987151e2..64e93181 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -804,6 +804,17 @@ class FolkCampaignPlanner extends HTMLElement { switch (node.type) { case 'post': { const d = node.data as PostNodeData; + const hasPostiz = !!d.postizPostId; + const postizBadge = hasPostiz + ? `
+ ${d.postizStatus === 'published' + ? `\u2713 Published${d.publishedAt ? ' ' + new Date(d.publishedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}${d.postizReleaseURL ? ` — open` : ''}` + : d.postizStatus === 'failed' + ? `\u26a0 Postiz error: ${esc(d.postizError || 'unknown')}` + : `\u23f3 Queued in Postiz (${esc(d.postizPostId || '')})`} +
` + : ''; + const canSend = !hasPostiz && !!d.content?.trim(); body = `
@@ -826,6 +837,10 @@ class FolkCampaignPlanner extends HTMLElement { + ${postizBadge} +
`; break; } @@ -1070,11 +1085,50 @@ class FolkCampaignPlanner extends HTMLElement { this.aiFillNode(node.id); } else if (action === 'generate-brief') { this.generateFromBriefNode(node.id); + } else if (action === 'send-postiz') { + this.sendNodeToPostiz(node.id, el as HTMLButtonElement); } }); }); } + private async sendNodeToPostiz(nodeId: string, btn: HTMLButtonElement) { + if (!this.currentFlowId) return; + // Flush any pending save first so the server has the latest content/scheduledAt. + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.executeSave(); + // Give Automerge sync a beat to propagate before we read on the server. + await new Promise(r => setTimeout(r, 400)); + } + const originalLabel = btn.textContent; + btn.disabled = true; + btn.textContent = 'Sending…'; + try { + const res = await fetch( + `${this.basePath}api/campaign/flows/${encodeURIComponent(this.currentFlowId)}/nodes/${encodeURIComponent(nodeId)}/send-postiz`, + { method: 'POST' }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + // Reflect server-side update locally so the inspector re-renders without waiting for sync. + const node = this.nodes.find(n => n.id === nodeId); + if (node && node.type === 'post') { + const d = node.data as PostNodeData; + d.postizPostId = data.postizPostId; + d.postizStatus = 'queued'; + d.postizSentAt = Date.now(); + if (d.status === 'draft') d.status = 'scheduled'; + } + btn.textContent = 'Sent!'; + setTimeout(() => { this.enterInlineEdit(nodeId); this.drawCanvasContent(); }, 600); + } catch (err: any) { + btn.textContent = (err.message || 'Error').slice(0, 40); + setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 3000); + } + } + private nodeIcon(type: CampaignNodeType): string { switch (type) { case 'post': return '📝'; @@ -1258,7 +1312,11 @@ class FolkCampaignPlanner extends HTMLElement { const platform = d.platform || 'x'; const color = PLATFORM_COLORS[platform] || '#888'; const icon = PLATFORM_ICONS[platform] || platform.charAt(0); - const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const statusColor = d.postizStatus === 'published' || d.status === 'published' ? '#22c55e' + : d.postizStatus === 'failed' ? '#ef4444' + : d.postizStatus === 'queued' ? '#a855f7' + : d.status === 'scheduled' ? '#3b82f6' + : '#f59e0b'; const time = item.date ? item.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : ''; return `
${icon} @@ -1339,7 +1397,11 @@ class FolkCampaignPlanner extends HTMLElement { const cards = items.map(node => { const d = node.data as PostNodeData; - const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const statusColor = d.postizStatus === 'published' || d.status === 'published' ? '#22c55e' + : d.postizStatus === 'failed' ? '#ef4444' + : d.postizStatus === 'queued' ? '#a855f7' + : d.status === 'scheduled' ? '#3b82f6' + : '#f59e0b'; const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Unscheduled'; const preview = (d.content || '').split('\n')[0].substring(0, 60); const tags = d.hashtags.slice(0, 3).map(h => @@ -1396,7 +1458,11 @@ class FolkCampaignPlanner extends HTMLElement { const platform = d.platform || 'x'; const color = PLATFORM_COLORS[platform] || '#888'; const icon = PLATFORM_ICONS[platform] || platform.charAt(0); - const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const statusColor = d.postizStatus === 'published' || d.status === 'published' ? '#22c55e' + : d.postizStatus === 'failed' ? '#ef4444' + : d.postizStatus === 'queued' ? '#a855f7' + : d.status === 'scheduled' ? '#3b82f6' + : '#f59e0b'; const statusLabel = d.status ? d.status.charAt(0).toUpperCase() + d.status.slice(1) : 'Draft'; const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; const preview = (d.content || '').split('\n')[0].substring(0, 50); @@ -1627,7 +1693,11 @@ class FolkCampaignPlanner extends HTMLElement { const platform = d.platform || 'x'; const color = PLATFORM_COLORS[platform] || '#888'; const icon = PLATFORM_ICONS[platform] || platform.charAt(0); - const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b'; + const statusColor = d.postizStatus === 'published' || d.status === 'published' ? '#22c55e' + : d.postizStatus === 'failed' ? '#ef4444' + : d.postizStatus === 'queued' ? '#a855f7' + : d.status === 'scheduled' ? '#3b82f6' + : '#f59e0b'; const preview = (d.content || '').split('\n')[0].substring(0, 50); const charCount = (d.content || '').length; const charMax = platform === 'x' ? 280 : 2200; diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index b249e82a..125674f5 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -72,6 +72,34 @@ export async function createPost( return res.json(); } +/** GET /public/v1/posts — list posts in a date range (used for status reconciliation). */ +export interface PostizListedPost { + id: string; + content?: string; + publishDate?: string; + releaseURL?: string; + state: 'QUEUE' | 'PUBLISHED' | 'ERROR' | 'DRAFT'; + integration?: { id: string; providerIdentifier?: string; name?: string }; +} + +export async function listPosts( + config: PostizConfig, + startDate: Date, + endDate: Date, +): Promise { + const qs = new URLSearchParams({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }); + const res = await postizFetch(config, `/public/v1/posts?${qs}`); + if (!res.ok) throw new Error(`Postiz listPosts error: ${res.status}`); + const data = await res.json(); + // Postiz wraps list as { posts: [...] }; be defensive against raw array. + if (Array.isArray(data)) return data as PostizListedPost[]; + if (Array.isArray(data?.posts)) return data.posts as PostizListedPost[]; + return []; +} + /** Create a thread — sends multiple grouped posts sharing a group ID. */ export async function createThread( config: PostizConfig, diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index e79754fb..80a8097e 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -31,16 +31,21 @@ import { } from "./lib/image-gen"; import { DEMO_FEED } from "./lib/types"; import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy"; -import { getPostizConfig, getIntegrations, createPost, createThread } from "./lib/postiz-client"; +import { getPostizConfig, getIntegrations, createPost, createThread, listPosts } from "./lib/postiz-client"; import { verifyToken, extractToken } from "../../server/auth"; import type { EncryptIDClaims } from "../../server/auth"; import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; let _syncServer: SyncServer | null = null; +let _postizSweepTimer: ReturnType | null = null; const routes = new Hono(); +// Sweep the postiz lead window: anything with scheduledAt within this many ms +// and not yet pushed to Postiz will be sent. Past-due posts push as type:'now'. +const POSTIZ_SWEEP_LEAD_MS = 10 * 60 * 1000; // 10 minutes + // ── Automerge doc management ── function ensureDoc(space: string): SocialsDoc { @@ -797,6 +802,246 @@ routes.post("/api/postiz/posts", async (c) => { } }); +// Resolve an integration id from a platform name against a live Postiz integrations list. +function resolveIntegrationId(integrations: any[], platform: string): string | null { + if (!platform) return integrations[0]?.id ?? null; + const needle = platform.toLowerCase(); + const match = integrations.find((i: any) => + i.name?.toLowerCase().includes(needle) || + i.providerIdentifier?.toLowerCase().includes(needle) + ); + return match?.id ?? integrations[0]?.id ?? null; +} + +// Push a single campaign-flow Post node to Postiz. Writes postizPostId + status +// back onto the node via Automerge so every client sees the update. +async function sendCampaignNodeToPostiz( + space: string, + flowId: string, + nodeId: string, +): Promise<{ ok: true; postizPostId: string } | { ok: false; error: string; code: number }> { + const config = await getPostizConfig(space); + if (!config) return { ok: false, error: "Postiz not configured for this space", code: 404 }; + + const docId = socialsDocId(space); + const doc = _syncServer!.getDoc(docId); + const flow = doc?.campaignFlows?.[flowId]; + if (!flow) return { ok: false, error: "Flow not found", code: 404 }; + const node = flow.nodes.find(n => n.id === nodeId); + if (!node || node.type !== 'post') return { ok: false, error: "Post node not found", code: 404 }; + + const data = node.data as PostNodeData; + if (data.postizPostId) return { ok: false, error: "Already sent to Postiz", code: 409 }; + + const content = (data.content || '').trim(); + if (!content) return { ok: false, error: "Post has no content", code: 400 }; + + let integrationIds: string[]; + try { + const integrations = await getIntegrations(config); + if (!Array.isArray(integrations) || integrations.length === 0) { + return { ok: false, error: "No Postiz integrations configured", code: 400 }; + } + const id = data.postizIntegrationId || resolveIntegrationId(integrations, data.platform); + if (!id) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 }; + integrationIds = [id]; + } catch (err: any) { + return { ok: false, error: `Postiz integrations fetch failed: ${err.message}`, code: 502 }; + } + + const scheduledAt = data.scheduledAt ? new Date(data.scheduledAt) : null; + const now = new Date(); + const type: 'schedule' | 'now' = !scheduledAt || scheduledAt.getTime() <= now.getTime() ? 'now' : 'schedule'; + const hashtagLine = data.hashtags?.length ? '\n\n' + data.hashtags.map(h => h.startsWith('#') ? h : `#${h}`).join(' ') : ''; + const payload = { + content: content + hashtagLine, + integrationIds, + type, + scheduledAt: type === 'schedule' ? scheduledAt!.toISOString() : undefined, + }; + + try { + const result = await createPost(config, payload); + // Postiz returns either { id } or an array — be defensive. + const postizPostId = (result as any)?.id + || (Array.isArray(result) && (result[0] as any)?.id) + || (result as any)?.posts?.[0]?.id + || ''; + + _syncServer!.changeDoc(docId, `postiz send ${nodeId}`, (d) => { + const f = d.campaignFlows?.[flowId]; + if (!f) return; + const n = f.nodes.find(x => x.id === nodeId); + if (!n || n.type !== 'post') return; + const nd = n.data as PostNodeData; + nd.postizPostId = postizPostId || `postiz-${Date.now()}`; + nd.postizIntegrationId = integrationIds[0]; + nd.postizStatus = 'queued'; + nd.postizSentAt = Date.now(); + nd.postizError = ''; + if (nd.status === 'draft') nd.status = 'scheduled'; + f.updatedAt = Date.now(); + }); + + return { ok: true, postizPostId }; + } catch (err: any) { + _syncServer!.changeDoc(docId, `postiz send failed ${nodeId}`, (d) => { + const f = d.campaignFlows?.[flowId]; + const n = f?.nodes.find(x => x.id === nodeId); + if (!n || n.type !== 'post') return; + const nd = n.data as PostNodeData; + nd.postizStatus = 'failed'; + nd.postizError = err.message || 'Unknown error'; + }); + return { ok: false, error: err.message || 'Postiz createPost failed', code: 502 }; + } +} + +// Periodic sweep: for every rsocials doc, find post nodes with scheduledAt +// approaching (within lead window) or already past, status === 'scheduled', +// and no postizPostId yet — push them to Postiz. +async function postizSweep() { + if (!_syncServer) return; + const allDocIds = _syncServer.listDocs?.() || []; + const now = Date.now(); + for (const docId of allDocIds) { + if (!docId.endsWith(':socials:data')) continue; + const space = docId.split(':')[0]; + if (!space) continue; + + let config; + try { config = await getPostizConfig(space); } catch { continue; } + if (!config) continue; // Skip spaces without Postiz wired up + + const doc = _syncServer.getDoc(docId); + if (!doc?.campaignFlows) continue; + + for (const flow of Object.values(doc.campaignFlows)) { + for (const node of flow.nodes) { + if (node.type !== 'post') continue; + const d = node.data as PostNodeData; + if (d.postizPostId) continue; + if (d.status !== 'scheduled') continue; + if (!d.scheduledAt) continue; + const ts = new Date(d.scheduledAt).getTime(); + if (Number.isNaN(ts)) continue; + if (ts - now > POSTIZ_SWEEP_LEAD_MS) continue; // Too far out — wait + + try { + const res = await sendCampaignNodeToPostiz(space, flow.id, node.id); + if (res.ok) { + console.log(`[rsocials:postiz-sweep] sent node=${node.id} space=${space} postiz=${res.postizPostId}`); + } else { + console.warn(`[rsocials:postiz-sweep] skip node=${node.id} space=${space} reason=${res.error}`); + } + } catch (err: any) { + console.error(`[rsocials:postiz-sweep] error node=${node.id}: ${err.message}`); + } + } + } + } +} + +// Reconcile local queued posts against Postiz. For each space with Postiz +// wired up, query posts in the window covering the oldest queued node, then +// flip statuses to 'published' or 'failed' based on Postiz state. +async function postizReconcile() { + if (!_syncServer) return; + const allDocIds = _syncServer.listDocs?.() || []; + const now = Date.now(); + + for (const docId of allDocIds) { + if (!docId.endsWith(':socials:data')) continue; + const space = docId.split(':')[0]; + if (!space) continue; + + // Collect queued nodes first — skip the API call if nothing to reconcile. + const doc = _syncServer.getDoc(docId); + if (!doc?.campaignFlows) continue; + + const queuedRefs: { flowId: string; nodeId: string; postizPostId: string; sentAt: number }[] = []; + for (const flow of Object.values(doc.campaignFlows)) { + for (const node of flow.nodes) { + if (node.type !== 'post') continue; + const d = node.data as PostNodeData; + if (!d.postizPostId) continue; + if (d.postizStatus !== 'queued') continue; + queuedRefs.push({ + flowId: flow.id, + nodeId: node.id, + postizPostId: d.postizPostId, + sentAt: d.postizSentAt || now, + }); + } + } + if (queuedRefs.length === 0) continue; + + let config; + try { config = await getPostizConfig(space); } catch { continue; } + if (!config) continue; + + // Query window: oldest queued send minus 1h, through 30 days out. + const oldest = Math.min(...queuedRefs.map(q => q.sentAt)); + const startDate = new Date(oldest - 60 * 60 * 1000); + const endDate = new Date(now + 30 * 24 * 60 * 60 * 1000); + + let listed: Awaited>; + try { + listed = await listPosts(config, startDate, endDate); + } catch (err: any) { + console.warn(`[rsocials:postiz-reconcile] list failed space=${space}: ${err.message}`); + continue; + } + const byId = new Map(listed.map(p => [p.id, p])); + + _syncServer.changeDoc(docId, `postiz reconcile ${space}`, (d) => { + for (const ref of queuedRefs) { + const remote = byId.get(ref.postizPostId); + const flow = d.campaignFlows?.[ref.flowId]; + const node = flow?.nodes.find(n => n.id === ref.nodeId); + if (!node || node.type !== 'post') continue; + const nd = node.data as PostNodeData; + nd.postizCheckedAt = now; + if (!remote) continue; // Postiz doesn't know the ID — maybe out of window + if (remote.state === 'PUBLISHED') { + nd.postizStatus = 'published'; + nd.status = 'published'; + nd.publishedAt = remote.publishDate ? Date.parse(remote.publishDate) : now; + if (remote.releaseURL) nd.postizReleaseURL = remote.releaseURL; + } else if (remote.state === 'ERROR') { + nd.postizStatus = 'failed'; + nd.postizError = 'Postiz reported publish error'; + } + // QUEUE / DRAFT: leave as-is. + } + }); + } +} + +function startPostizScheduler() { + if (_postizSweepTimer) return; + _postizSweepTimer = setInterval(() => { + postizSweep().catch(() => {}); + postizReconcile().catch(() => {}); + }, 60_000); + setTimeout(() => { + postizSweep().catch(() => {}); + postizReconcile().catch(() => {}); + }, 30_000); + console.log('[rsocials] Postiz scheduler sweep started (60s interval, 10min lead, status reconcile)'); +} + +routes.post("/api/campaign/flows/:flowId/nodes/:nodeId/send-postiz", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const flowId = c.req.param("flowId"); + const nodeId = c.req.param("nodeId"); + + const result = await sendCampaignNodeToPostiz(dataSpace, flowId, nodeId); + if (!result.ok) return c.json({ error: result.error }, result.code as any); + return c.json({ ok: true, postizPostId: result.postizPostId }); +}); + routes.post("/api/postiz/threads", async (c) => { const space = c.req.param("space") || "demo"; const config = await getPostizConfig(space); @@ -2514,8 +2759,8 @@ routes.get("/campaign-flow", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - styles: ``, - scripts: ``, + styles: ``, + scripts: ``, })); }); @@ -2879,6 +3124,7 @@ export const socialsModule: RSpaceModule = { _syncServer = ctx.syncServer; // Run migration for any existing file-based threads try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ } + startPostizScheduler(); }, externalApp: { url: POSTIZ_URL, name: "Postiz" }, feeds: [ diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index d7166003..d1f71ed8 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -66,6 +66,14 @@ export interface PostNodeData { scheduledAt: string; status: 'draft' | 'scheduled' | 'published'; hashtags: string[]; + postizPostId?: string; + postizIntegrationId?: string; + postizStatus?: 'queued' | 'published' | 'failed'; + postizError?: string; + postizSentAt?: number; + postizCheckedAt?: number; + postizReleaseURL?: string; + publishedAt?: number; } export interface ThreadNodeData { From 878646a3997da403b4d7b0aebc57866a64be0374 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:40:17 -0400 Subject: [PATCH 2/7] fix(rsocials): Postiz public API uses raw key + /api prefix, not Bearer Postiz's Nest backend expects Authorization: (no "Bearer" prefix) and the public API is reverse-proxied at /api/public/v1/*. Our client was hitting /public/v1/* directly and using Bearer, which 401'd. Tolerate both config.url shapes (with or without /api) so older module settings that point at the root host keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/rsocials/lib/postiz-client.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index 125674f5..bc285d52 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -28,19 +28,26 @@ export async function getPostizConfig(spaceSlug: string): Promise`). The API lives at `/api/public/v1/*`; we + * tolerate either `config.url = 'https://host'` or `config.url = 'https://host/api'` + * by inserting `/api` when missing. + */ export async function postizFetch( config: PostizConfig, path: string, opts: RequestInit = {}, ): Promise { const headers = new Headers(opts.headers); - headers.set("Authorization", `Bearer ${config.apiKey}`); + headers.set("Authorization", config.apiKey); if (!headers.has("Content-Type") && opts.body) { headers.set("Content-Type", "application/json"); } - return fetch(`${config.url}${path}`, { ...opts, headers }); + const base = config.url.endsWith('/api') ? config.url : `${config.url}/api`; + return fetch(`${base}${path}`, { ...opts, headers }); } /** GET /public/v1/integrations — list connected social channels. */ From 1cde95f3fc53743ed14ee91998813f34643d6f62 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:49:18 -0400 Subject: [PATCH 3/7] fix(rsocials): Postiz createPost/createThread use nested posts[] shape Postiz's /public/v1/posts expects a nested envelope: { type, date, shortLink, tags, posts: [{ integration:{id}, value:[{content}], settings:{__type} }] } The earlier shim's flat {content, integrationIds, type, scheduledAt} was rejected with "All posts must have an integration id" (the field is named integration, not integrationIds; it's a nested object, not an array of strings). Also: Postiz treats PostItem.value[] as thread segments, so createThread now sends ONE request with multiple value entries rather than N grouped posts. Callers now pass full {id, identifier} tuples so settings.__type is set correctly (otherwise Postiz can't route to the provider handler). Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/rsocials/lib/postiz-client.ts | 69 ++++++++++++---- modules/rsocials/mod.ts | 112 +++++++++++++++----------- 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index bc285d52..4d83907c 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -57,20 +57,44 @@ export async function getIntegrations(config: PostizConfig) { return res.json(); } -/** POST /public/v1/posts — create a single post (draft, schedule, or now). */ +/** POST /public/v1/posts — create a single post (draft, schedule, or now). + * + * Postiz expects a nested shape: + * { type, date, shortLink, tags, posts: [{ integration: {id}, value: [{content}], settings: {__type} }] } + * + * Callers pass integrations as {id, identifier} tuples; identifier populates + * settings.__type which Postiz uses to route to the correct provider handler. + */ +export interface PostizIntegrationRef { + id: string; + identifier: string; // e.g. 'x', 'linkedin', 'bluesky' +} + export async function createPost( config: PostizConfig, payload: { content: string; - integrationIds: string[]; + integrations: PostizIntegrationRef[]; type: 'draft' | 'schedule' | 'now'; scheduledAt?: string; group?: string; }, ) { + const body = { + type: payload.type, + date: payload.scheduledAt || new Date().toISOString(), + shortLink: false, + tags: [] as unknown[], + posts: payload.integrations.map(integ => ({ + integration: { id: integ.id }, + value: [{ content: payload.content, image: [] as unknown[] }], + ...(payload.group ? { group: payload.group } : {}), + settings: { __type: integ.identifier }, + })), + }; const res = await postizFetch(config, "/public/v1/posts", { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); @@ -107,27 +131,38 @@ export async function listPosts( return []; } -/** Create a thread — sends multiple grouped posts sharing a group ID. */ +/** Create a thread — single Postiz post whose `value` array carries each tweet. + * + * Postiz treats the `value` array on a PostItem as the thread segments, so we + * send ONE request with multiple value entries, not N grouped requests. + */ export async function createThread( config: PostizConfig, tweets: string[], opts: { - integrationIds: string[]; + integrations: PostizIntegrationRef[]; type: 'draft' | 'schedule' | 'now'; scheduledAt?: string; }, ) { - const group = `thread-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - const results = []; - for (const content of tweets) { - const result = await createPost(config, { - content, - integrationIds: opts.integrationIds, - type: opts.type, - scheduledAt: opts.scheduledAt, - group, - }); - results.push(result); + const body = { + type: opts.type, + date: opts.scheduledAt || new Date().toISOString(), + shortLink: false, + tags: [] as unknown[], + posts: opts.integrations.map(integ => ({ + integration: { id: integ.id }, + value: tweets.map(t => ({ content: t, image: [] as unknown[] })), + settings: { __type: integ.identifier }, + })), + }; + const res = await postizFetch(config, "/public/v1/posts", { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Postiz createThread error: ${res.status} ${text}`); } - return { group, posts: results }; + return res.json(); } diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 80a8097e..6ec5383a 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -31,7 +31,7 @@ import { } from "./lib/image-gen"; import { DEMO_FEED } from "./lib/types"; import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy"; -import { getPostizConfig, getIntegrations, createPost, createThread, listPosts } from "./lib/postiz-client"; +import { getPostizConfig, getIntegrations, createPost, createThread, listPosts, type PostizIntegrationRef } from "./lib/postiz-client"; import { verifyToken, extractToken } from "../../server/auth"; import type { EncryptIDClaims } from "../../server/auth"; import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; @@ -790,9 +790,16 @@ routes.post("/api/postiz/posts", async (c) => { } try { + const fetched = await getIntegrations(config); + const byId = new Map((fetched || []).map((i: any) => [i.id, i])); + const integrations: PostizIntegrationRef[] = integrationIds + .map((id: string) => byId.get(id)) + .filter(Boolean) + .map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier })); + if (!integrations.length) return c.json({ error: "No integration matched the requested IDs" }, 400); const result = await createPost(config, { content, - integrationIds, + integrations, type: type || 'draft', scheduledAt, }); @@ -803,14 +810,25 @@ routes.post("/api/postiz/posts", async (c) => { }); // Resolve an integration id from a platform name against a live Postiz integrations list. -function resolveIntegrationId(integrations: any[], platform: string): string | null { - if (!platform) return integrations[0]?.id ?? null; +// Resolve a full integration tuple {id, identifier} from a platform name. +// Postiz's public API returns integrations with `identifier` (e.g. 'x'), while +// the internal/admin shape uses `providerIdentifier`. Exact match on either +// beats a loose name includes(). +function resolveIntegration(integrations: any[], platform: string): PostizIntegrationRef | null { + const toRef = (i: any): PostizIntegrationRef | null => { + const id = i?.id; + const identifier = i?.identifier || i?.providerIdentifier; + return id && identifier ? { id, identifier } : null; + }; + if (!platform) return toRef(integrations[0]); const needle = platform.toLowerCase(); - const match = integrations.find((i: any) => - i.name?.toLowerCase().includes(needle) || - i.providerIdentifier?.toLowerCase().includes(needle) + const exact = integrations.find((i: any) => + i.identifier?.toLowerCase() === needle || + i.providerIdentifier?.toLowerCase() === needle ); - return match?.id ?? integrations[0]?.id ?? null; + if (exact) return toRef(exact); + const loose = integrations.find((i: any) => i.name?.toLowerCase().includes(needle)); + return toRef(loose) || toRef(integrations[0]); } // Push a single campaign-flow Post node to Postiz. Writes postizPostId + status @@ -836,15 +854,20 @@ async function sendCampaignNodeToPostiz( const content = (data.content || '').trim(); if (!content) return { ok: false, error: "Post has no content", code: 400 }; - let integrationIds: string[]; + let integrations: PostizIntegrationRef[]; try { - const integrations = await getIntegrations(config); - if (!Array.isArray(integrations) || integrations.length === 0) { + const fetched = await getIntegrations(config); + if (!Array.isArray(fetched) || fetched.length === 0) { return { ok: false, error: "No Postiz integrations configured", code: 400 }; } - const id = data.postizIntegrationId || resolveIntegrationId(integrations, data.platform); - if (!id) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 }; - integrationIds = [id]; + let ref: PostizIntegrationRef | null = null; + if (data.postizIntegrationId) { + const matched = fetched.find((i: any) => i.id === data.postizIntegrationId); + if (matched) ref = { id: matched.id, identifier: matched.identifier || matched.providerIdentifier }; + } + ref = ref || resolveIntegration(fetched, data.platform); + if (!ref) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 }; + integrations = [ref]; } catch (err: any) { return { ok: false, error: `Postiz integrations fetch failed: ${err.message}`, code: 502 }; } @@ -855,7 +878,7 @@ async function sendCampaignNodeToPostiz( const hashtagLine = data.hashtags?.length ? '\n\n' + data.hashtags.map(h => h.startsWith('#') ? h : `#${h}`).join(' ') : ''; const payload = { content: content + hashtagLine, - integrationIds, + integrations, type, scheduledAt: type === 'schedule' ? scheduledAt!.toISOString() : undefined, }; @@ -875,7 +898,7 @@ async function sendCampaignNodeToPostiz( if (!n || n.type !== 'post') return; const nd = n.data as PostNodeData; nd.postizPostId = postizPostId || `postiz-${Date.now()}`; - nd.postizIntegrationId = integrationIds[0]; + nd.postizIntegrationId = integrations[0].id; nd.postizStatus = 'queued'; nd.postizSentAt = Date.now(); nd.postizError = ''; @@ -1054,20 +1077,25 @@ routes.post("/api/postiz/threads", async (c) => { } // If no integrationIds provided, try to auto-detect from configured integrations - let ids = integrationIds; - if (!ids?.length) { - try { - const integrations = await getIntegrations(config); - ids = (integrations || []).map((i: any) => i.id).slice(0, 1); - } catch { /* fall through */ } - } - if (!ids?.length) { - return c.json({ error: "No integrationIds provided and no integrations found" }, 400); + let integrations: PostizIntegrationRef[] = []; + try { + const fetched = await getIntegrations(config); + const byId = new Map((fetched || []).map((i: any) => [i.id, i])); + const ids: string[] = Array.isArray(integrationIds) && integrationIds.length + ? integrationIds + : (fetched || []).slice(0, 1).map((i: any) => i.id); + integrations = ids + .map((id: string) => byId.get(id)) + .filter(Boolean) + .map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier })); + } catch { /* fall through */ } + if (!integrations.length) { + return c.json({ error: "No integrations provided and no integrations found" }, 400); } try { const result = await createThread(config, tweets, { - integrationIds: ids, + integrations, type: type || 'draft', scheduledAt, }); @@ -1421,29 +1449,24 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { switch (node.type) { case 'post-to-platform': { if (!postizConfig) throw new Error('Postiz not configured'); - const integrations = await getIntegrations(postizConfig); - const platformName = (cfg.platform as string || '').toLowerCase(); - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName) - ); - const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id); + const fetched = await getIntegrations(postizConfig); + const ref = resolveIntegration(fetched || [], (cfg.platform as string) || ''); + if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`); const content = (cfg.content as string || '') + (cfg.hashtags ? '\n' + cfg.hashtags : ''); - await createPost(postizConfig, { content, integrationIds, type: 'draft' }); + await createPost(postizConfig, { content, integrations: [ref], type: 'draft' }); results.push({ nodeId: node.id, status: 'success', message: `Draft created on ${cfg.platform || 'default'}`, durationMs: Date.now() - start }); break; } case 'cross-post': { if (!postizConfig) throw new Error('Postiz not configured'); const platforms = (cfg.platforms as string || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean); - const integrations = await getIntegrations(postizConfig); + const fetched = await getIntegrations(postizConfig); const content = (cfg.content as string || ''); const posted: string[] = []; for (const plat of platforms) { - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(plat) || i.providerIdentifier?.toLowerCase().includes(plat) - ); - if (match) { - await createPost(postizConfig, { content, integrationIds: [match.id], type: 'draft' }); + const ref = resolveIntegration(fetched || [], plat); + if (ref) { + await createPost(postizConfig, { content, integrations: [ref], type: 'draft' }); posted.push(plat); } } @@ -1461,13 +1484,10 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { } const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean); if (tweets.length === 0) throw new Error('No thread content or thread ID provided'); - const integrations = await getIntegrations(postizConfig); - const platformName = (cfg.platform as string || '').toLowerCase(); - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName) - ); - const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id); - await createThread(postizConfig, tweets, { integrationIds, type: 'draft' }); + const fetched = await getIntegrations(postizConfig); + const ref = resolveIntegration(fetched || [], (cfg.platform as string) || ''); + if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`); + await createThread(postizConfig, tweets, { integrations: [ref], type: 'draft' }); results.push({ nodeId: node.id, status: 'success', message: `Thread draft created (${tweets.length} tweets)`, durationMs: Date.now() - start }); break; } From b2fbe7ded434390cc79aaa55193ede66052194be Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:56:23 -0400 Subject: [PATCH 4/7] fix(rsocials): postiz sweep skips nodes with any postizStatus set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier version only skipped when postizPostId was set. If a send attempt failed (no postizPostId but postizStatus='failed'), the sweep retried every 60s and hammered Postiz's throttler. Retries now require the user to clear postizStatus — same gate as a fresh node. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/rsocials/mod.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 6ec5383a..94d7b4cb 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -944,6 +944,9 @@ async function postizSweep() { if (node.type !== 'post') continue; const d = node.data as PostNodeData; if (d.postizPostId) continue; + // Skip any node that's already been handled by Postiz (queued/published/failed). + // A failed send needs the user to clear postizStatus before we retry. + if (d.postizStatus) continue; if (d.status !== 'scheduled') continue; if (!d.scheduledAt) continue; const ts = new Date(d.scheduledAt).getTime(); From 6a335e4dbafe0c4ce91a21b11c233eba5a4025fd Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 11:03:21 -0400 Subject: [PATCH 5/7] fix(rsocials): include Postiz platform-specific settings defaults Postiz's POST /public/v1/posts validates required per-platform fields on settings (e.g. X needs who_can_reply_post). Added defaultSettingsFor() covering x, linkedin, instagram, youtube. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/rsocials/lib/postiz-client.ts | 32 ++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index 4d83907c..18b6b22f 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -60,7 +60,7 @@ export async function getIntegrations(config: PostizConfig) { /** POST /public/v1/posts — create a single post (draft, schedule, or now). * * Postiz expects a nested shape: - * { type, date, shortLink, tags, posts: [{ integration: {id}, value: [{content}], settings: {__type} }] } + * { type, date, shortLink, tags, posts: [{ integration: {id}, value: [{content}], settings: {__type, ...} }] } * * Callers pass integrations as {id, identifier} tuples; identifier populates * settings.__type which Postiz uses to route to the correct provider handler. @@ -70,6 +70,32 @@ export interface PostizIntegrationRef { identifier: string; // e.g. 'x', 'linkedin', 'bluesky' } +// Platform-specific settings Postiz validates on POST /posts. Keys that are +// required-non-empty for each provider live here. We merge defaults when the +// caller doesn't supply overrides. +function defaultSettingsFor(identifier: string): Record { + const base: Record = { __type: identifier }; + switch (identifier) { + case 'x': + base.who_can_reply_post = 'everyone'; + break; + case 'linkedin': + base.post_to_company = false; + break; + case 'instagram': + base.post_type = 'post'; + break; + case 'youtube': + base.type = 'public'; + base.title = ''; + base.category = '22'; + break; + // Others (bluesky, threads, mastodon, reddit, ...) don't require settings + // beyond __type as of Postiz 5.x. + } + return base; +} + export async function createPost( config: PostizConfig, payload: { @@ -89,7 +115,7 @@ export async function createPost( integration: { id: integ.id }, value: [{ content: payload.content, image: [] as unknown[] }], ...(payload.group ? { group: payload.group } : {}), - settings: { __type: integ.identifier }, + settings: defaultSettingsFor(integ.identifier), })), }; const res = await postizFetch(config, "/public/v1/posts", { @@ -153,7 +179,7 @@ export async function createThread( posts: opts.integrations.map(integ => ({ integration: { id: integ.id }, value: tweets.map(t => ({ content: t, image: [] as unknown[] })), - settings: { __type: integ.identifier }, + settings: defaultSettingsFor(integ.identifier), })), }; const res = await postizFetch(config, "/public/v1/posts", { From 06b84bf2d71891de6caf3fd565775ebd2bfefdde Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 11:08:47 -0400 Subject: [PATCH 6/7] fix(rsocials): list-and-match fallback to recover postizPostId after create Postiz's POST /public/v1/posts response shape doesn't reliably include the new post's id (varies by version). When parsing fails, query listPosts in a narrow window right after create and match by integration+content to recover the real id. Without this, reconcile can't find queued posts back in Postiz. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/rsocials/mod.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 94d7b4cb..84d7093a 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -885,12 +885,28 @@ async function sendCampaignNodeToPostiz( try { const result = await createPost(config, payload); - // Postiz returns either { id } or an array — be defensive. - const postizPostId = (result as any)?.id + // Postiz returns either { id } or an array — be defensive on parse. + let postizPostId: string = (result as any)?.id || (Array.isArray(result) && (result[0] as any)?.id) || (result as any)?.posts?.[0]?.id || ''; + // Fallback: Postiz's create response shape varies by version and often + // doesn't include the new post id. Query listPosts in a narrow window, + // then find by integration + content to recover the real id. + if (!postizPostId) { + try { + const windowStart = new Date(Date.now() - 60_000); + const windowEnd = new Date(Date.now() + 365 * 86400_000); + const listed = await listPosts(config, windowStart, windowEnd); + const match = listed.find(p => + p.integration?.id === integrations[0].id && + (p.content || '').trim() === payload.content.trim() + ); + if (match) postizPostId = match.id; + } catch { /* leave id empty, reconcile will treat as lost */ } + } + _syncServer!.changeDoc(docId, `postiz send ${nodeId}`, (d) => { const f = d.campaignFlows?.[flowId]; if (!f) return; From 6f8d938e1fcaa042351738a2ea5ee7d2af6ead87 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 11:23:50 -0400 Subject: [PATCH 7/7] fix(presence): keep peers online across rApp navigation in same space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop beforeunload leave broadcast — was flashing users offline during intra-space page nav between rApps - Heartbeat 10s → 5s; GC stale threshold 15s → 12s - Presence payload now carries userId; overlay keys peer map by userId so reconnects with a new WS peerId map back to the same row - uniquePeers() prefers entries with module/context metadata so the panel shows which rApp a peer is in, even alongside cursor-only awareness --- shared/collab-presence.ts | 19 +++++------ shared/components/rstack-collab-overlay.ts | 38 ++++++++++++++++++---- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/shared/collab-presence.ts b/shared/collab-presence.ts index 975859fb..f1f12058 100644 --- a/shared/collab-presence.ts +++ b/shared/collab-presence.ts @@ -57,6 +57,7 @@ export function broadcastPresence(opts: PresenceOpts): void { noteId: opts.noteId, itemId: opts.itemId, username: session.username, + userId: session.userId, color: userColor(session.userId), }); } @@ -75,21 +76,17 @@ export function broadcastLeave(): void { } /** - * Start a 10-second heartbeat that broadcasts presence. + * Start a 5-second heartbeat that broadcasts presence. * Returns a cleanup function to stop the heartbeat. * + * No beforeunload leave — navigating between rApps in the same space would + * otherwise flash the user offline to peers. Peers GC us after ~12s if we + * truly close the tab. + * * @param getOpts - Called each heartbeat to get current presence state */ export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void { broadcastPresence(getOpts()); - const timer = setInterval(() => broadcastPresence(getOpts()), 10_000); - - // Clean up immediately on page unload - const onUnload = () => broadcastLeave(); - window.addEventListener('beforeunload', onUnload); - - return () => { - clearInterval(timer); - window.removeEventListener('beforeunload', onUnload); - }; + const timer = setInterval(() => broadcastPresence(getOpts()), 5_000); + return () => clearInterval(timer); } diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index 97e4f0bd..2ccb6fb9 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -27,6 +27,7 @@ const PEER_COLORS = [ interface PeerState { peerId: string; + userId?: string; // stable identity across page nav / peerId churn username: string; color: string; cursor: { x: number; y: number } | null; @@ -242,16 +243,25 @@ export class RStackCollabOverlay extends HTMLElement { this.#connectToDoc(); } - // Listen for space-wide presence broadcasts (module context from all rApps) + // Listen for space-wide presence broadcasts (module context from all rApps). + // Key by userId when available so a peer navigating between rApps (new WS peerId + // each page) maps back to the same row instead of flashing offline. this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => { if (!msg.username) return; - // Use peerId from server relay, or fall back to a hash of username const pid = msg.peerId || `anon-${msg.username}`; if (pid === this.#localPeerId) return; // ignore self - const existing = this.#peers.get(pid); - this.#peers.set(pid, { + const key = msg.userId || pid; + // Drop any stale entry keyed by the old peerId for this same userId + if (msg.userId) { + for (const [k, p] of this.#peers) { + if (k !== key && p.userId === msg.userId) this.#peers.delete(k); + } + } + const existing = this.#peers.get(key); + this.#peers.set(key, { peerId: pid, + userId: msg.userId || existing?.userId, username: msg.username || existing?.username || 'Anonymous', color: msg.color || existing?.color || this.#colorForPeer(pid), cursor: existing?.cursor ?? null, @@ -499,7 +509,7 @@ export class RStackCollabOverlay extends HTMLElement { #gcPeers() { const now = Date.now(); - const staleThreshold = this.#externalPeers ? 30000 : 15000; + const staleThreshold = this.#externalPeers ? 30000 : 12000; let changed = false; for (const [id, peer] of this.#peers) { if (now - peer.lastSeen > staleThreshold) { @@ -522,13 +532,27 @@ export class RStackCollabOverlay extends HTMLElement { } } - /** Deduplicate peers by username, keeping the most recently seen entry per user. */ + /** + * Deduplicate peers for display. Keyed by username (lowercase) so a user + * appearing twice — e.g. old awareness entry (peerId-keyed) alongside a new + * presence entry (userId-keyed) after navigating rApps — collapses to one + * row. Prefers entries carrying module/context metadata so the panel shows + * which rApp the peer is in. + */ #uniquePeers(): PeerState[] { const byName = new Map(); for (const peer of this.#peers.values()) { const key = peer.username.toLowerCase(); const existing = byName.get(key); - if (!existing || peer.lastSeen > existing.lastSeen) { + if (!existing) { + byName.set(key, peer); + continue; + } + const peerHasMeta = !!(peer.module || peer.context); + const existingHasMeta = !!(existing.module || existing.context); + if (peerHasMeta && !existingHasMeta) { + byName.set(key, peer); + } else if (peerHasMeta === existingHasMeta && peer.lastSeen > existing.lastSeen) { byName.set(key, peer); } }