Action

Post Thread

Posted by agiletortoise, Last update about 1 month ago

Post a thread to Mastodon. To use, write the full content of the thread in a single draft, with === (three equal signs) on lines where you want the posts to be divided.

This action will:

  • Split the text of the current draft into separate posts, using the “===“ as a divider.
  • Display a preview of the thread as it will be posted. Any errors will be displayed - e.g. if any of the blocks are too long to be a single post.
  • If you continue from the preview, the thread will be posted with the first post as a public post, and subsequent posts a unlisted replies.

IMPORTANT: Before using this action, edit the first to “Define Template Tag” steps to enter the Mastodon instance host and credential identifier to use.

For more examples, see our Mastodon Integration Guide

Steps

  • defineTemplateTag

    name
    mastodon-host
    template
    your.mastodon
  • defineTemplateTag

    name
    credential-id
    template
    @agiletortoise
  • script

    // max characters per post. Leave room for the thread marker
    const maxCharacters = 490
    
    // separator to find where to break up the text for the thread
    const separator = "==="
    const thread = "🧵"
    
    // grab host-id from tags
    let host = draft.processTemplate("[[mastodon-host]]")
    let credentialID = draft.processTemplate("[[credential-id]]")
    
    // validate values
    if (host.length == 0 || host == "your.mastodon") {
    	alert(`Mastodon host must be configured to run this action.
    
    Edit action and set your Mastodon host name (like "mastodon.social") as the template value in the first "Define Template Tag" action step.
    `)
    	context.cancel()
    }
  • script

    // Utility functions
    function dividePosts(input) {
    	var posts = input.split(separator + "\n");
    	return posts.map(x => x.trim());
    }
    function countString(current, total) {
    	return ` (${current}/${total}) ${thread}`;
    }
    
    function isPostValid(post) {
    	return post.length <= maxCharacters && post.length > 0;
    }
    function areAllPostsValid(posts) {
    	if (posts.length == 0) { return false; }
    	for (var post of posts) {
    		if (!isPostValid(post)) {
    			return false;
    		}
    	}
    	return true;
    }
    function htmlSafe(s) {
    	return s.replace(/</g, "&lt;").replace(/\n/g, "<br>\n");
    }
    
    // build HTML preview display the resulting tweets.
    var html = ["<html><head>"];
    html.push("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
    html.push("<style>");
    html.push("body { background: #666; color: #444; font-family: system-ui, -apple-system; margin: 1em auto; max-width:360px; }");
    html.push("p {padding: 1em; background-color: #eee; }");
    html.push("p.error{ background-color: #FFCDDD; color: maroon; font-weight: bold; padding: 1.5em; }\n");
    html.push("p.invalid{ color: maroon; }\n");
    html.push("p.reply { border-left: 5px solid #bbb; }");
    html.push("p.preview{ font-style:italic; background-color: #444; color: #aaa; font-size: .9em; }\n");
    html.push("span.note{ background-color: maroon; color: white; font-weight: bold; border-radius:3px; padding: .25em; font-size: .8em; }\n");
    html.push("</style>");
    html.push("</head><body>");
    
    const posts = dividePosts(draft.content);
    var isValid = areAllPostsValid(posts);
    
    if (!isValid) {
    	html.push("<p class='error'>");
    	html.push(`This draft cannot be posted as a post storm. Be sure to divide the draft into blocks of ${maxCharacters} characters or shorter, with each post separated by a line containing the text “${separator}”`);
    	html.push("</p>");
    }
    else {
    	html.push("<p class='preview'>");
    	html.push("Preview posts as they will be posted below. Tap continue to post, cancel to go back without posting.");
    	html.push("</p>");
    }
    
    if (posts.length > 0) {
    	var ct = 1;
    	for (var post of posts) {
    		if (isPostValid(post)) {
    			html.push("<p class='valid" + (ct > 1 ? " reply" : "") + "'>");
    			html.push(htmlSafe(post));
    			html.push(countString(ct, posts.length));
    			html.push("</p>");
    		}
    		else {
    			html.push("<p class='invalid" + (ct > 1 ? " reply" : "") + "'>");
    			html.push("<span class='note'>" + post.length + " characters</span> ");
    			html.push(htmlSafe(post));
    			html.push(countString(ct, posts.length));
    			html.push("</p>");
    		}
    		ct++;
    	}
    }
    else {
    	html.push("<p>Nothing to post</p>");
    }
    
    html.push("</body></html>");
    draft.setTemplateTag("html", html.join("\n"));
    
    
  • htmlpreview

    [[html]]
  • script

    // If we reach this script
    // We have a valid set of posts ready
    // in the `posts` const, we just need to post them...
    
    // sleep method to use to avoid rate limiting
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
    
    if (isValid) {
    	// create Mastodon instance
    	let m = Mastodon.create(host, credentialID)
    	const path = "/api/v1/statuses"
    	let vis = "public" // will change to unlisted for replies
    
    	let ct = 1
    	let inReplyTo = ""
    	let hasError = false
    
    	for (let post of posts) {
    		const content = post + countString(ct, posts.length);
    		
    		let data = {
    			"status": content,
    			"visibility": vis
    		}
    		// if this is the first post
    		// set the reply information to thread post storm
    		if (ct != 1) {
    			data["in_reply_to_id"] = inReplyTo
    		}
    		// make API request
    		let response = m.request({
    			"path": path,
    			"method": "POST",
    			"data": data
    		})
    
    		if (!response.success) {
    			console.log(`Post Failed: ${response.statusCode}, ${response.error}`)
    			hasError = true
    			break
    		}
    		else {
    			inReplyTo = response.responseData["id"]
    			console.log(`Posted to Mastodon: ${response.responseData["url"]}`)
    		}
    
    		ct++
    		vis = "unlisted"
    		sleep(500) // avoid rate limiting with little pause
    	}
    	if (!hasError) {
    		console.log("Post storm posted");
    	}
    	else {
    		console.log("Post storm failed");
    		context.fail();
    	}
    }
    else {
    	context.cancel();
    }

Options

  • After Success Default
    Notification Info
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.