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) =>