feat(rsocials): connect drafted threads to campaign flows via picker dropdowns

Replace manual thread ID entry with select dropdowns in both campaign planner
and workflow components. Server-side publish-thread handler now resolves
linked threadId from Automerge doc when inline content is empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 14:13:31 -07:00
parent d31e8fdca4
commit aca0e6b353
3 changed files with 108 additions and 8 deletions

View File

@ -744,19 +744,31 @@ class FolkCampaignPlanner extends HTMLElement {
} }
case 'thread': { case 'thread': {
const d = node.data as ThreadNodeData; const d = node.data as ThreadNodeData;
const threads = this.localFirstClient?.listThreads() || [];
const threadOptions = threads.length === 0
? '<option value="">No threads yet</option>'
: [`<option value="">— Select a thread —</option>`,
...threads.map(t =>
`<option value="${t.id}" ${d.threadId === t.id ? 'selected' : ''}>${esc(t.title || 'Untitled')} (${t.tweets.length} tweets)</option>`
)].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 = ` body = `
<div class="cp-icp-body"> <div class="cp-icp-body">
<label>Title</label> <label>Thread</label>
<input data-field="label" value="${esc(d.label)}" readonly/> <select data-field="threadId" data-thread-picker="true">${threadOptions}</select>
${previewText ? `<div style="font-size:11px;color:var(--text-secondary,#94a3b8);margin:4px 0 8px;line-height:1.4">${previewText}</div>` : ''}
<label>Tweet Count</label> <label>Tweet Count</label>
<input data-field="tweetCount" type="number" value="${d.tweetCount}"/> <input data-field="tweetCount" type="number" value="${d.tweetCount}" readonly/>
<label>Status</label> <label>Status</label>
<select data-field="status"> <select data-field="status">
<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option> <option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="ready" ${d.status === 'ready' ? 'selected' : ''}>Ready</option> <option value="ready" ${d.status === 'ready' ? 'selected' : ''}>Ready</option>
<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option> <option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
</select> </select>
<button class="cp-btn" data-action="open-thread" style="margin-top:8px;width:100%">Open in Thread Editor</button> <button class="cp-btn" data-action="open-thread" style="margin-top:8px;width:100%" ${!d.threadId ? 'disabled' : ''}>Open in Thread Editor</button>
</div>`; </div>`;
break; break;
} }
@ -808,7 +820,27 @@ class FolkCampaignPlanner extends HTMLElement {
const field = el.getAttribute('data-field')!; const field = el.getAttribute('data-field')!;
const handler = () => { const handler = () => {
const val = (el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value; 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); (node.data as PostNodeData).hashtags = val.split(',').map(h => h.trim()).filter(Boolean);
} else if (field === 'tweetCount') { } else if (field === 'tweetCount') {
(node.data as ThreadNodeData).tweetCount = parseInt(val, 10) || 0; (node.data as ThreadNodeData).tweetCount = parseInt(val, 10) || 0;

View File

@ -9,13 +9,15 @@
* space space slug (default "demo") * space space slug (default "demo")
*/ */
import { CAMPAIGN_NODE_CATALOG } from '../schemas'; import { CAMPAIGN_NODE_CATALOG, socialsDocId } from '../schemas';
import type { import type {
CampaignWorkflowNodeDef, CampaignWorkflowNodeDef,
CampaignWorkflowNodeCategory, CampaignWorkflowNodeCategory,
CampaignWorkflowNode, CampaignWorkflowNode,
CampaignWorkflowEdge, CampaignWorkflowEdge,
CampaignWorkflow, CampaignWorkflow,
ThreadData,
SocialsDoc,
} from '../schemas'; } from '../schemas';
// ── Constants ── // ── Constants ──
@ -125,6 +127,10 @@ class FolkCampaignWorkflow extends HTMLElement {
// Execution log // Execution log
private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = []; 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 // Bound listeners
private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
@ -172,6 +178,20 @@ class FolkCampaignWorkflow extends HTMLElement {
requestAnimationFrame(() => this.fitView()); 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) { private loadWorkflow(wf: CampaignWorkflow) {
this.currentWorkflowId = wf.id; this.currentWorkflowId = wf.id;
this.workflowName = wf.name; this.workflowName = wf.name;
@ -480,8 +500,28 @@ class FolkCampaignWorkflow extends HTMLElement {
const def = getNodeDef(node.type); const def = getNodeDef(node.type);
if (!def) return ''; 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 fieldsHtml = def.configSchema.map(field => {
const val = node.config[field.key] ?? ''; 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
? '<option value="">No threads yet</option>'
: [`<option value="">— Select a thread —</option>`,
...threads.map(t =>
`<option value="${t.id}" ${val === t.id ? 'selected' : ''}>${esc(t.title || 'Untitled')} (${t.tweets.length} tweets)</option>`
)].join('');
return `
<div class="cw-config__field">
<label>${esc(field.label)}</label>
<select data-config-key="threadId" id="config-thread-picker">${threadOptions}</select>
</div>`;
}
if (field.type === 'select') { if (field.type === 'select') {
const options = (field.options || []).map(o => const options = (field.options || []).map(o =>
`<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>` `<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`
@ -855,6 +895,28 @@ class FolkCampaignWorkflow extends HTMLElement {
if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); 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', () => { this.shadow.getElementById('config-open-thread')?.addEventListener('click', () => {
const node = this.nodes.find(n => n.id === this.selectedNodeId); const node = this.nodes.find(n => n.id === this.selectedNodeId);
if (!node) return; if (!node) return;

View File

@ -959,9 +959,15 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
} }
case 'publish-thread': { case 'publish-thread': {
if (!postizConfig) throw new Error('Postiz not configured'); 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); 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 integrations = await getIntegrations(postizConfig);
const platformName = (cfg.platform as string || '').toLowerCase(); const platformName = (cfg.platform as string || '').toLowerCase();
const match = (integrations || []).find((i: any) => const match = (integrations || []).find((i: any) =>