Schedule posts to Bluesky with Cloudflare workers. skyscheduler.work
cf tool bsky-tool cloudflare bluesky schedule bsky service social-media cloudflare-workers

improve the post layout generation

less requirements of raw DOM, more react responses

also closes #87

+202 -106
+4 -1
assets/css/stylesheet.css
··· 13 13 14 14 .postItemHeader { 15 15 min-height: 50px; 16 + button:has(+ button) { 17 + margin-right: 10px; 18 + } 16 19 } 17 20 18 21 .repostScheduleSimple { ··· 144 147 display: inline-block; 145 148 } 146 149 147 - #refresh-posts > span, #settingsButton > span { 150 + #refresh-posts > span, #settingsButton > span, .repostIcon { 148 151 margin-right: 5px; 149 152 } 150 153
+4 -3
src/endpoints/post.tsx
··· 4 4 import { validate as isValid } from 'uuid'; 5 5 import { ContextVariables } from "../auth"; 6 6 import { PostEdit } from "../layout/editPost"; 7 - import { ScheduledPost, ScheduledPostList } from "../layout/postList"; 7 + import { PostHTML } from "../layout/post"; 8 + import { ScheduledPostList } from "../layout/postList"; 8 9 import { authMiddleware } from "../middleware/auth"; 9 10 import { corsHelperMiddleware } from "../middleware/corsHelper"; 10 11 import { ··· 174 175 originalPost.text = content; 175 176 c.header("HX-Trigger-After-Settle", `{"scrollListToPost": "${id}"}`); 176 177 c.header("HX-Trigger-After-Swap", "postUpdatedNotice, timeSidebar, scrollTop"); 177 - return c.html(<ScheduledPost post={originalPost} dynamic={true} />); 178 + return c.html(<PostHTML post={originalPost} dynamic={true} />); 178 179 } 179 180 180 181 c.header("HX-Trigger-After-Settle", swapErrEvents); ··· 190 191 // Get the original post to replace with 191 192 if (postInfo !== null) { 192 193 c.header("HX-Trigger-After-Swap", "timeSidebar, scrollListTop, scrollTop"); 193 - return c.html(<ScheduledPost post={postInfo} dynamic={true} />); 194 + return c.html(<PostHTML post={postInfo} dynamic={true} />); 194 195 } 195 196 196 197 // Refresh sidebar otherwise
+2 -2
src/layout/editPost.tsx
··· 1 1 import { MAX_LENGTH } from "../limits"; 2 2 import { EmbedDataType, Post } from "../types.d"; 3 - import { PostContentObject } from "./postList"; 3 + import { PostContent } from "./post"; 4 4 5 5 type EditedPostProps = { 6 6 post: Post; ··· 45 45 export function PostEdit({post}:EditedPostProps) { 46 46 // If this post is posted, just show the same object again. 47 47 if (post.posted) { 48 - return (<PostContentObject text={post.text} posted={true} repost={false} />); 48 + return (<PostContent text={post.text} posted={true} repost={false} />); 49 49 } 50 50 51 51 const editSpinner: string = `editSpinner${post.postid}`;
+1 -1
src/layout/makePost.tsx
··· 12 12 } from "../limits"; 13 13 import { PreloadRules } from "../types.d"; 14 14 import { ConstScriptPreload } from "../utils/constScriptGen"; 15 - import { ContentLabelOptions } from "./options/contentLabelOptions"; 16 15 import { IncludeDependencyTags } from "./helpers/includesTags"; 16 + import { ContentLabelOptions } from "./options/contentLabelOptions"; 17 17 import { RetweetOptions } from "./options/retweetOptions"; 18 18 import { ScheduleOptions } from "./options/scheduleOptions"; 19 19
+43
src/layout/post.tsx
··· 1 + import { html } from "hono/html"; 2 + import { MAX_POSTED_LENGTH } from "../limits"; 3 + import { Post } from "../types.d"; 4 + import { PostDataFooter, PostDataHeader } from "./posts/wrappers"; 5 + 6 + type PostContentProps = { 7 + text: string; 8 + posted: boolean; 9 + repost: boolean; 10 + }; 11 + 12 + export function PostContent(props: PostContentProps) { 13 + const ellipses = props.posted && !props.repost && props.text.length >= (MAX_POSTED_LENGTH-1) ? "..." : ""; 14 + return (<p class="postText">{props.text}{ellipses}</p>); 15 + }; 16 + 17 + type ScheduledPostOptions = { 18 + post: Post; 19 + // if the object should be dynamically replaced. 20 + // usually in edit/cancel edit settings. 21 + dynamic?: boolean; 22 + }; 23 + 24 + export function PostHTML(props: ScheduledPostOptions) { 25 + const content: Post = props.post; 26 + const oobSwapStr = (props.dynamic) ? `hx-swap-oob="#postBase${content.postid}"` : ""; 27 + const hasBeenPosted: boolean = (content.posted === true && content.uri !== undefined); 28 + 29 + const postHTML = html` 30 + <article 31 + id="postBase${content.postid}" ${oobSwapStr}> 32 + ${<PostDataHeader content={content} posted={hasBeenPosted} />} 33 + <div id="post${content.postid}"> 34 + ${<PostContent text={content.text} posted={content.posted || false} repost={content.isRepost || false} />} 35 + </div> 36 + ${<PostDataFooter content={content} posted={hasBeenPosted} />} 37 + </article>`; 38 + // if this is a thread, chain it nicely 39 + if (content.isChildPost) 40 + return html`<blockquote>${postHTML}</blockquote>`; 41 + 42 + return postHTML; 43 + };
+2 -99
src/layout/postList.tsx
··· 1 1 import { Context } from "hono"; 2 - import { html, raw } from "hono/html"; 3 2 import isEmpty from "just-is-empty"; 4 3 import { Post } from "../types.d"; 5 4 import { getPostsForUser } from "../utils/dbQuery"; 6 - import { MAX_POSTED_LENGTH } from "../limits"; 7 - 8 - type PostContentObjectProps = { 9 - text: string; 10 - posted: boolean; 11 - repost: boolean; 12 - }; 13 - 14 - export function PostContentObject(props: PostContentObjectProps) { 15 - const ellipses = props.posted && !props.repost && props.text.length >= (MAX_POSTED_LENGTH-1) ? "..." : ""; 16 - return (<p class="postText">{props.text}{ellipses}</p>); 17 - } 18 - 19 - type ScheduledPostOptions = { 20 - post: Post; 21 - // if the object should be dynamically replaced. 22 - // usually in edit/cancel edit settings. 23 - dynamic?: boolean; 24 - } 25 - 26 - export function ScheduledPost(props: ScheduledPostOptions) { 27 - const content: Post = props.post; 28 - const oobSwapStr = (props.dynamic) ? `hx-swap-oob="#postBase${content.postid}"` : ""; 29 - const hasBeenPosted: boolean = (content.posted === true && content.uri !== undefined); 30 - const postURIID: string|null = content.uri ? content.uri.replace("at://","").replace("app.bsky.feed.","") : null; 31 - 32 - const postType = content.isRepost ? "repost" : "post"; 33 - const postOnText = content.isRepost ? "Repost on" : "Posted on"; 34 - const deleteReplace = `hx-target="${content.isChildPost ? 'blockquote:has(' : ''}#postBase${content.postid}${content.isChildPost ? ')' :''}"`; 35 - const editAttributes = hasBeenPosted ? '' : raw(`title="Click to edit post content" hx-get="/post/edit/${content.postid}" 36 - hx-trigger="click once" hx-target="#post${content.postid}" hx-swap="innerHTML show:#editPost${content.postid}:top"`); 37 - const deletePostElement = raw(`<button type="submit" hx-delete="/post/delete/${content.postid}" 38 - hx-confirm="Are you sure you want to delete this ${postType}?" title="Click to delete this ${postType}" 39 - data-placement="left" data-tooltip="Delete this ${postType}" ${raw(deleteReplace)} 40 - hx-swap="outerHTML" hx-trigger="click" class="btn-sm btn-error outline btn-delete"> 41 - <img src="/icons/trash.svg" alt="trash icon" width="20px" height="20px" /> 42 - </button>`); 43 - const editPostElement = raw(`<button class="editPostKeyboard btn-sm primary outline" 44 - data-tooltip="Edit this post" data-placement="right" ${editAttributes}> 45 - <img src="/icons/edit.svg" alt="edit icon" width="20px" height="20px" /> 46 - </button>`); 47 - const threadItemElement = raw(`<button class="addThreadPost btn-sm primary outline" data-tooltip="Add a post to thread" 48 - data-placement="right" listen="false"> 49 - <img src="/icons/reply.svg" alt="threaded post icon" width="20px" height="20px" /> 50 - </button>`); 51 - 52 - let repostInfoStr:string = ""; 53 - if (!isEmpty(content.repostInfo)) { 54 - for (const repostItem of content.repostInfo!) { 55 - if (repostItem.count >= 1) { 56 - const repostWrapper = `<span class="timestamp">${repostItem.time}</span>`; 57 - if (repostItem.count == 1 && repostItem.hours == 0) 58 - repostInfoStr += `* Repost at ${repostWrapper}`; 59 - else 60 - repostInfoStr += `* Every ${repostItem.hours} hours, ${repostItem.count} times from ${repostWrapper}`; 61 - repostInfoStr += "\n"; 62 - } 63 - } 64 - } 65 - const repostCountElement = content.repostCount ? 66 - (<> | <span class="repostTimesLeft" tabindex={0} data-placement="left"> 67 - <span class="repostInfoData" hidden={true}>{raw(repostInfoStr)}</span>Reposts Left: {content.repostCount}</span></>) : ""; 68 - 69 - // This is only really good for debugging, this attribute isn't used anywhere else. 70 - const parentMetaAttr = (content.isChildPost) ? `data-parent="${content.parentPost}"` : ""; 71 - const canSeeHeader = !hasBeenPosted || (content.isRepost && content.repostCount! > 0); 72 - 73 - const postHTML = html` 74 - <article 75 - id="postBase${content.postid}" ${oobSwapStr}> 76 - <header class="postItemHeader" data-item="${content.postid}" data-root="${content.rootPost || content.postid}" ${raw(parentMetaAttr)} 77 - ${canSeeHeader ? raw('>') : raw(`hidden>`)} 78 - ${!hasBeenPosted ? editPostElement : null} 79 - ${!hasBeenPosted ? threadItemElement : null} 80 - ${canSeeHeader ? deletePostElement : null} 81 - </header> 82 - <div id="post${content.postid}"> 83 - ${<PostContentObject text={content.text} posted={content.posted || false} repost={content.isRepost || false} />} 84 - </div> 85 - <footer> 86 - <small> 87 - ${hasBeenPosted ? 88 - raw(`<a class="secondary" data-uri="${content.uri}" href="https://bsky.app/profile/${postURIID}" 89 - target="_blank" title="link to post">${postOnText}</a>:`) : 90 - 'Scheduled for:' } 91 - <span class="timestamp">${content.scheduledDate}</span> 92 - ${!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : ''} 93 - ${repostCountElement} 94 - </small> 95 - </footer> 96 - </article>`; 97 - // if this is a thread, chain it nicely 98 - if (content.isChildPost) 99 - return html`<blockquote>${postHTML}</blockquote>`; 100 - 101 - return postHTML; 102 - }; 5 + import { PostHTML } from "./post"; 103 6 104 7 type ScheduledPostListProps = { 105 8 ctx?: Context; ··· 113 16 <> 114 17 <a hidden tabindex={-1} class="invalidateTab hidden"></a> 115 18 {response!.map((data: Post) => { 116 - return <ScheduledPost post={data} />; 19 + return <PostHTML post={data} />; 117 20 })} 118 21 </> 119 22 );
+47
src/layout/posts/buttons.tsx
··· 1 + 2 + export function AddPostToThreadButton() { 3 + return ( 4 + <button class="addThreadPost btn-sm primary outline" data-tooltip="Add a post to thread" 5 + data-placement="right" listen="false"> 6 + <img src="/icons/reply.svg" alt="threaded post icon" width="20px" height="20px" /> 7 + </button> 8 + ); 9 + } 10 + 11 + type PostIDProps = { 12 + id: string; 13 + } 14 + 15 + export function EditPostButton({id}: PostIDProps) { 16 + return ( 17 + <button class="editPostKeyboard btn-sm primary outline" 18 + data-tooltip="Edit this post" data-placement="right" 19 + hx-trigger="click once" 20 + title="Click to edit post content" 21 + hx-get={`/post/edit/${id}`} 22 + hx-target={`#post${id}`} 23 + hx-swap={`innerHTML show:#editPost${id}:top"`}> 24 + <img src="/icons/edit.svg" alt="edit icon" width="20px" height="20px" /> 25 + </button> 26 + ); 27 + } 28 + 29 + type DeletePostProps = PostIDProps & { 30 + child: boolean; 31 + isRepost?: boolean; 32 + } 33 + 34 + export function DeletePostButton(props: DeletePostProps) { 35 + const deleteTargetId = `#postBase${props.id}`; 36 + const postType = props.isRepost ? "repost" : "post"; 37 + const deleteTarget = props.child ? `blockquote:has(${deleteTargetId})` : deleteTargetId; 38 + return ( 39 + <button type="submit" hx-delete={`/post/delete/${props.id}`} 40 + hx-confirm={`Are you sure you want to delete this ${postType}?`} 41 + title={`Click to delete this ${postType}`} 42 + data-placement="left" data-tooltip={`Delete this ${postType}`} hx-target={deleteTarget} 43 + hx-swap="outerHTML" hx-trigger="click" class="btn-sm btn-error outline btn-delete"> 44 + <img src="/icons/trash.svg" alt="trash icon" width="20px" height="20px" /> 45 + </button> 46 + ); 47 + };
+49
src/layout/posts/repostData.tsx
··· 1 + import { raw } from "hono/html"; 2 + import isEmpty from "just-is-empty"; 3 + import { RepostInfo } from "../../types.d"; 4 + 5 + type RepostIconProps = { 6 + isRepost?: boolean; 7 + }; 8 + 9 + export function RepostIcon(props: RepostIconProps) { 10 + if (props.isRepost === true) { 11 + return ( 12 + <span> 13 + <img src="/icons/repost.svg" class="repostIcon" alt="reposted post icon" width="20px" height="20px" /> 14 + <small>&nbsp;Reposted Post</small> 15 + </span> 16 + ); 17 + } 18 + return null; 19 + }; 20 + 21 + type RepostCountProps = { 22 + count?: number; 23 + repostInfo?: RepostInfo[]; 24 + }; 25 + 26 + export function RepostCountElement(props: RepostCountProps) { 27 + if (props.count === undefined || props.count <= 0) { 28 + return null; 29 + } 30 + let repostInfoStr: string = ""; 31 + if (!isEmpty(props.repostInfo)) { 32 + for (const repostItem of props.repostInfo!) { 33 + if (repostItem.count >= 1) { 34 + const repostWrapper = `<span class="timestamp">${repostItem.time}</span>`; 35 + if (repostItem.count == 1 && repostItem.hours == 0) 36 + repostInfoStr += `* Repost at ${repostWrapper}`; 37 + else 38 + repostInfoStr += `* Every ${repostItem.hours} hours, ${repostItem.count} times from ${repostWrapper}`; 39 + repostInfoStr += "\n"; 40 + } 41 + } 42 + } 43 + return ( 44 + <> | <span class="repostTimesLeft" tabindex={0} data-placement="left"> 45 + <span class="repostInfoData" hidden={true}>{raw(repostInfoStr)}</span> 46 + Reposts Left: {props.count}</span> 47 + </> 48 + ); 49 + };
+50
src/layout/posts/wrappers.tsx
··· 1 + import { raw } from "hono/html"; 2 + import isEmpty from "just-is-empty"; 3 + import { Post } from "../../types.d"; 4 + import { AddPostToThreadButton, DeletePostButton, EditPostButton } from "./buttons"; 5 + import { RepostCountElement, RepostIcon } from "./repostData"; 6 + 7 + type PostDataHeaderOptions = { 8 + content: Post; 9 + posted: boolean; 10 + }; 11 + 12 + export function PostDataHeader(props: PostDataHeaderOptions) { 13 + const content: Post = props.content; 14 + const canSeeHeader = !props.posted || (content.isRepost && content.repostCount! > 0); 15 + 16 + return ( 17 + <header class="postItemHeader" data-item={content.postid} data-root={content.rootPost || content.postid} 18 + data-parent={content.isChildPost ? content.parentPost : undefined} 19 + hidden={canSeeHeader ? undefined: true}> 20 + <RepostIcon isRepost={content.isRepost} /> 21 + {!props.posted ? <EditPostButton id={content.postid} /> : null} 22 + {!props.posted ? <AddPostToThreadButton /> : null} 23 + {canSeeHeader ? <DeletePostButton id={content.postid} isRepost={content.isRepost} child={content.isChildPost} /> : null} 24 + </header>); 25 + }; 26 + 27 + 28 + type PostDataFooterOptions = { 29 + content: Post; 30 + posted: boolean; 31 + }; 32 + 33 + export function PostDataFooter(props: PostDataFooterOptions) { 34 + const content: Post = props.content; 35 + const postURIID: string|null = content.uri ? content.uri.replace("at://","").replace("app.bsky.feed.","") : null; 36 + const hasPosted: boolean = props.posted; 37 + return ( 38 + <footer> 39 + <small> 40 + <a class="secondary" hidden={!hasPosted} tabindex={hasPosted ? undefined : -1} 41 + data-uri={content.uri} 42 + href={`https://bsky.app/profile/${postURIID}`} 43 + target="_blank" title="link to post">{content.isRepost ? "Repost on" : "Posted on"}</a> 44 + <span hidden={hasPosted}>Scheduled for</span>: 45 + &nbsp;<span class="timestamp">{raw(content.scheduledDate!)}</span> 46 + {!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : null} 47 + <RepostCountElement count={content.repostCount} repostInfo={content.repostInfo} /> 48 + </small> 49 + </footer>); 50 + };