diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index c2f3019..8b00658 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -744,19 +744,31 @@ class FolkCampaignPlanner extends HTMLElement { } case 'thread': { const d = node.data as ThreadNodeData; + const threads = this.localFirstClient?.listThreads() || []; + const threadOptions = threads.length === 0 + ? '' + : [``, + ...threads.map(t => + `` + )].join(''); + const selectedThread = d.threadId ? threads.find(t => t.id === d.threadId) : null; + const previewText = selectedThread?.tweets[0] + ? esc(selectedThread.tweets[0].substring(0, 120)) + (selectedThread.tweets[0].length > 120 ? '...' : '') + : ''; body = `
- - + + + ${previewText ? `
${previewText}
` : ''} - + - +
`; break; } @@ -808,7 +820,27 @@ class FolkCampaignPlanner extends HTMLElement { const field = el.getAttribute('data-field')!; const handler = () => { const val = (el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value; - if (field === 'hashtags') { + if (field === 'threadId' && (el as HTMLElement).hasAttribute('data-thread-picker')) { + const d = node.data as ThreadNodeData; + if (val) { + const thread = this.localFirstClient?.listThreads().find(t => t.id === val); + if (thread) { + d.threadId = thread.id; + d.label = thread.title || 'Untitled'; + d.tweetCount = thread.tweets.length; + d.preview = thread.tweets[0]?.substring(0, 120) || ''; + d.status = 'draft'; + } + } else { + d.threadId = ''; + d.label = 'New Thread'; + d.tweetCount = 0; + d.preview = ''; + } + this.scheduleSave(); + this.drawCanvasContent(); + return; + } else if (field === 'hashtags') { (node.data as PostNodeData).hashtags = val.split(',').map(h => h.trim()).filter(Boolean); } else if (field === 'tweetCount') { (node.data as ThreadNodeData).tweetCount = parseInt(val, 10) || 0; diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts index 839d568..7e9c144 100644 --- a/modules/rsocials/components/folk-campaign-workflow.ts +++ b/modules/rsocials/components/folk-campaign-workflow.ts @@ -9,13 +9,15 @@ * space — space slug (default "demo") */ -import { CAMPAIGN_NODE_CATALOG } from '../schemas'; +import { CAMPAIGN_NODE_CATALOG, socialsDocId } from '../schemas'; import type { CampaignWorkflowNodeDef, CampaignWorkflowNodeCategory, CampaignWorkflowNode, CampaignWorkflowEdge, CampaignWorkflow, + ThreadData, + SocialsDoc, } from '../schemas'; // ── Constants ── @@ -125,6 +127,10 @@ class FolkCampaignWorkflow extends HTMLElement { // Execution log private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = []; + // Thread cache (for publish-thread node picker) + private cachedThreads: ThreadData[] = []; + private threadsLoaded = false; + // Bound listeners private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null; @@ -172,6 +178,20 @@ class FolkCampaignWorkflow extends HTMLElement { requestAnimationFrame(() => this.fitView()); } + private loadThreads() { + if (this.threadsLoaded) return; + this.threadsLoaded = true; + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = socialsDocId(this.space); + const doc = runtime.documents?.get(docId) as SocialsDoc | undefined; + if (doc?.threads) { + this.cachedThreads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt); + } + } catch { /* offline runtime not available */ } + } + private loadWorkflow(wf: CampaignWorkflow) { this.currentWorkflowId = wf.id; this.workflowName = wf.name; @@ -480,8 +500,28 @@ class FolkCampaignWorkflow extends HTMLElement { const def = getNodeDef(node.type); if (!def) return ''; + // Lazy-load threads when configuring a publish-thread node + if (node.type === 'publish-thread') this.loadThreads(); + const fieldsHtml = def.configSchema.map(field => { const val = node.config[field.key] ?? ''; + + // Replace threadId text input with a select dropdown for publish-thread nodes + if (field.key === 'threadId' && node.type === 'publish-thread') { + const threads = this.cachedThreads; + const threadOptions = threads.length === 0 + ? '' + : [``, + ...threads.map(t => + `` + )].join(''); + return ` +
+ + +
`; + } + if (field.type === 'select') { const options = (field.options || []).map(o => `` @@ -855,6 +895,28 @@ class FolkCampaignWorkflow extends HTMLElement { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); }); + this.shadow.getElementById('config-thread-picker')?.addEventListener('change', (e) => { + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return; + const threadId = (e.target as HTMLSelectElement).value; + node.config.threadId = threadId; + if (threadId) { + const thread = this.cachedThreads.find(t => t.id === threadId); + if (thread) { + node.config.threadContent = thread.tweets.join('\n---\n'); + node.label = thread.title || 'Publish Thread'; + // Update the threadContent textarea if visible + const ta = this.shadow.querySelector('[data-config-key="threadContent"]') as HTMLTextAreaElement | null; + if (ta) ta.value = node.config.threadContent as string; + // Update label input + const li = this.shadow.getElementById('config-label') as HTMLInputElement | null; + if (li) li.value = node.label; + this.drawCanvasContent(); + } + } + this.scheduleSave(); + }); + this.shadow.getElementById('config-open-thread')?.addEventListener('click', () => { const node = this.nodes.find(n => n.id === this.selectedNodeId); if (!node) return; diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 40d0dcf..9ccc335 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -959,9 +959,15 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { } case 'publish-thread': { if (!postizConfig) throw new Error('Postiz not configured'); - const threadContent = cfg.threadContent as string || ''; + let threadContent = cfg.threadContent as string || ''; + if (!threadContent && cfg.threadId) { + const linkedThread = doc.threads?.[cfg.threadId as string]; + if (linkedThread?.tweets?.length) { + threadContent = linkedThread.tweets.join('\n---\n'); + } + } const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean); - if (tweets.length === 0) throw new Error('No thread content'); + 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) =>