···10const postFormTitle = document.getElementById('postFormTitle')
11let hasFileLimit = false;
12let fileData = new Map();
01314/* Sections for handling UI changes and modifications */
15const sectionRetweet = document.getElementById('section-retweet');
···20function addOnUnloadBlocker() {
21 window.onbeforeunload = function() {
22 document.querySelectorAll(".fileDel").forEach((el) => {el.click();});
023 return undefined;
24 }
25}
26function clearOnUnloadBlocker() {
27 window.onbeforeunload = null;
0000028}
2930let fileDropzone = new Dropzone("#fileUploads", {
31 url: "/post/upload",
32 autoProcessQueue: true,
33- /* We process this ourselves */
34 addRemoveLinks: false,
35 maxFiles: FILE_DROP_MAX_FILES,
36 dictMaxFilesExceeded: "max files",
···80 fileDropzone.removeAllFiles();
81 // Clear the file data map
82 fileData.clear();
083});
8485fileDropzone.on("reset", () => {
86 hasFileLimit = false;
087 clearOnUnloadBlocker();
88 showContentLabeler(false);
89 setElementVisible(sectionLinkAttach, true);
···95 pushToast("Maximum number of files reached", false);
96 return;
97 }
0098 setElementVisible(sectionLinkAttach, false);
99 const buttonHolder = Dropzone.createElement("<fieldset role='group' class='file-item-group'></fieldset>");
100 const removeButton = Dropzone.createElement("<button class='fileDel outline btn-error' disabled><small>Remove file</small></button>");
···148fileDropzone.on("success", function(file, response) {
149 const deleteFileOnError = () => {
150 const delButton = file.previewElement.querySelectorAll(".fileDel")[0];
0151 delButton.setAttribute("bad", true);
152 delButton.click();
153 };
00000000154 // show the labels
155 showContentLabeler(true);
156 const fileIsImage = imageTypes.includes(file.type);
···170 videoTag.setAttribute("src", videoObjectURL);
171 videoTag.addEventListener("loadeddata", () => {
172 const videoDuration = videoTag.duration;
173- if (videoDuration > MAX_VIDEO_LENGTH) {
174- pushToast(`${file.name} is too long for bsky by ${(videoDuration - MAX_VIDEO_LENGTH).toFixed(2)} seconds`, false);
175- deleteFileOnError();
176- } else {
177- fileData.set(file.name, {content: response.data, type: 3,
178 height: videoTag.videoHeight, width: videoTag.videoWidth, duration: videoDuration });
179 hasFileLimit = true;
180 }
···196 let duration = 0;
197 try {
198 for (let i = 0, len = uint8.length; i < len; i++) {
199- if (uint8[i] == 0x21
200- && uint8[i + 1] == 0xF9
201- && uint8[i + 2] == 0x04
202- && uint8[i + 7] == 0x00)
203 {
204 const delay = (uint8[i + 5] << 8) | (uint8[i + 4] & 0xFF)
205 duration += delay < 2 ? 10 : delay
···224 if (videoDuration === null) {
225 pushToast(`${file.name} duration could not be processed`, false);
226 deleteFileOnError();
227- } else if (videoDuration > MAX_VIDEO_LENGTH) {
228- pushToast(`${file.name} is over the maximum video duration by ${(videoDuration - MAX_VIDEO_LENGTH).toFixed(2)} seconds`, false);
229- deleteFileOnError();
230- } else if (videoDuration >= MAX_GIF_LENGTH) {
231- pushToast(`${file.name} is over the maximum length for a gif by ${(videoDuration - MAX_GIF_LENGTH).toFixed(2)} seconds`, false);
232- deleteFileOnError();
233 } else {
234- fileData.set(file.name, { content: response.data, type: 3, height: imageHeight, width: imageWidth, duration: videoDuration });
235 hasFileLimit = true;
236 }
237 };
238 // Force the file to load.
239 imgObj.src = gifImgURL;
240 } else {
241- fileData.set(file.name, {content: response.data, type: 1});
242 }
243244 // Make the buttons pressable
···280 pushToast(`Error: ${file.name} had an unexpected error`, false);
281 }
28200283 fileDropzone.removeFile(file);
284 if (fileData.length == 0) {
285 setElementVisible(sectionLinkAttach, true);
···288289fileDropzone.on("uploadprogress", function(file, progress, bytesSent) {
290 const progressObject = file.previewElement.querySelector(".dz-upload");
291- progressObject.innerHTML = `${progress}%`;
292 if ((progress === 100 || bytesSent == file.size) && progressObject) {
293 progressObject.innerHTML = "Processing...please wait. This may take a bit!";
294 }
···301// Handle form submission
302postForm.addEventListener('submit', async (e) => {
303 e.preventDefault();
00000304 showPostProgress(true);
305 const contentVal = content.value;
306 const postNow = postNowCheckbox.checked;
···10const postFormTitle = document.getElementById('postFormTitle')
11let hasFileLimit = false;
12let fileData = new Map();
13+let waitingFiles = 0;
1415/* Sections for handling UI changes and modifications */
16const sectionRetweet = document.getElementById('section-retweet');
···21function addOnUnloadBlocker() {
22 window.onbeforeunload = function() {
23 document.querySelectorAll(".fileDel").forEach((el) => {el.click();});
24+ // only way to get the alert box to not show up
25 return undefined;
26 }
27}
28function clearOnUnloadBlocker() {
29 window.onbeforeunload = null;
30+}
31+32+function setFileData(fileName, data) {
33+ fileData.set(fileName, data);
34+ --waitingFiles;
35}
3637let fileDropzone = new Dropzone("#fileUploads", {
38 url: "/post/upload",
39 autoProcessQueue: true,
40+ /* We add remove links ourselves */
41 addRemoveLinks: false,
42 maxFiles: FILE_DROP_MAX_FILES,
43 dictMaxFilesExceeded: "max files",
···87 fileDropzone.removeAllFiles();
88 // Clear the file data map
89 fileData.clear();
90+ waitingFiles = 0;
91});
9293fileDropzone.on("reset", () => {
94 hasFileLimit = false;
95+ waitingFiles = 0;
96 clearOnUnloadBlocker();
97 showContentLabeler(false);
98 setElementVisible(sectionLinkAttach, true);
···104 pushToast("Maximum number of files reached", false);
105 return;
106 }
107+ // Increase the number of waiting to be processed files.
108+ ++waitingFiles;
109 setElementVisible(sectionLinkAttach, false);
110 const buttonHolder = Dropzone.createElement("<fieldset role='group' class='file-item-group'></fieldset>");
111 const removeButton = Dropzone.createElement("<button class='fileDel outline btn-error' disabled><small>Remove file</small></button>");
···159fileDropzone.on("success", function(file, response) {
160 const deleteFileOnError = () => {
161 const delButton = file.previewElement.querySelectorAll(".fileDel")[0];
162+ --waitingFiles;
163 delButton.setAttribute("bad", true);
164 delButton.click();
165 };
166+ const deleteFileIfLengthOver = (length, max) => {
167+ if (length > max) {
168+ pushToast(`${file.name} is over the max duration by ${(length - max).toFixed(2)} seconds`, false);
169+ deleteFileOnError();
170+ return true;
171+ }
172+ return false;
173+ }
174 // show the labels
175 showContentLabeler(true);
176 const fileIsImage = imageTypes.includes(file.type);
···190 videoTag.setAttribute("src", videoObjectURL);
191 videoTag.addEventListener("loadeddata", () => {
192 const videoDuration = videoTag.duration;
193+ if (!deleteFileIfLengthOver(videoDuration, MAX_VIDEO_LENGTH)) {
194+ setFileData(file.name, {content: response.data, type: 3,
000195 height: videoTag.videoHeight, width: videoTag.videoWidth, duration: videoDuration });
196 hasFileLimit = true;
197 }
···213 let duration = 0;
214 try {
215 for (let i = 0, len = uint8.length; i < len; i++) {
216+ if (uint8[i] == 0x21 && uint8[i + 1] == 0xF9
217+ && uint8[i + 2] == 0x04 && uint8[i + 7] == 0x00)
00218 {
219 const delay = (uint8[i + 5] << 8) | (uint8[i + 4] & 0xFF)
220 duration += delay < 2 ? 10 : delay
···239 if (videoDuration === null) {
240 pushToast(`${file.name} duration could not be processed`, false);
241 deleteFileOnError();
242+ } else if (deleteFileIfLengthOver(videoDuration, MAX_GIF_LENGTH)) {
243+ return;
0000244 } else {
245+ setFileData(file.name, { content: response.data, type: 3, height: imageHeight, width: imageWidth, duration: videoDuration });
246 hasFileLimit = true;
247 }
248 };
249 // Force the file to load.
250 imgObj.src = gifImgURL;
251 } else {
252+ setFileData(file.name, {content: response.data, type: 1});
253 }
254255 // Make the buttons pressable
···291 pushToast(`Error: ${file.name} had an unexpected error`, false);
292 }
293294+ // file failed to upload, so drop the value here too
295+ --waitingFiles;
296 fileDropzone.removeFile(file);
297 if (fileData.length == 0) {
298 setElementVisible(sectionLinkAttach, true);
···301302fileDropzone.on("uploadprogress", function(file, progress, bytesSent) {
303 const progressObject = file.previewElement.querySelector(".dz-upload");
304+ progressObject.innerHTML = `${progress.toFixed(2)}%`;
305 if ((progress === 100 || bytesSent == file.size) && progressObject) {
306 progressObject.innerHTML = "Processing...please wait. This may take a bit!";
307 }
···314// Handle form submission
315postForm.addEventListener('submit', async (e) => {
316 e.preventDefault();
317+ // prevent submissions if we're currently uploading files.
318+ if (waitingFiles > 0) {
319+ pushToast("Files are currently pending upload, please wait...", false);
320+ return;
321+ }
322 showPostProgress(true);
323 const contentVal = content.value;
324 const postNow = postNowCheckbox.checked;
+12-5
src/layout/makePost.tsx
···26 {type: "script", href: "/dep/tribute.min.js"}
27];
2829-export function PostCreation() {
030 const bskyImageLimits = `Max file size of ${BSKY_IMG_SIZE_LIMIT_IN_MB}MB`;
31 return (
32 <section>
···63 <li><span data-tooltip={BSKY_IMG_FILE_EXTS}>Images</span>:
64 <ul>
65 <li>must be less than {CF_IMAGES_MAX_DIMENSION}x{CF_IMAGES_MAX_DIMENSION} pixels</li>
66- <li>must have a file size smaller than {CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB ({APP_NAME} will attempt to compress images to fit <span data-tooltip={bskyImageLimits}>BlueSky's requirements</span>)</li>
00000067 <li>thumbnails will only be shown here for images that are smaller than {MAX_THUMBNAIL_SIZE}MB</li>
68- <li>don't upload and fail, it's recommended to use a lower resolution file instead</li>
69 </ul></li>
70 <li><span data-tooltip={BSKY_VIDEO_FILE_EXTS}>Videos</span>:
71 <ul>
72 <li>must be shorter than {BSKY_VIDEO_MAX_DURATION} minutes</li>
73 <li>must be smaller than {R2_FILE_SIZE_LIMIT_IN_MB}MB</li>
74- <li>will be processed on BSky after they're posted. This may show a temporary "Video not Found"/black screen for a bit after posting.</li>
75 </ul></li>
76 </ul></small></div>
77 </footer>
···95 <section>
96 <article>
97 <header>Insert Post/Feed/List Link</header>
98- <input id="recordBox" placeholder="https://" title="Must be a link to a ATProto powered record" />
99 <small>Posts must be quotable and all record types must exist upon the scheduled time. If it does not exist, it will not be attached to your post.</small>
100 </article>
101 </section>
···26 {type: "script", href: "/dep/tribute.min.js"}
27];
2829+export function PostCreation({ctx}: any) {
30+ const maxWidth: number|undefined = ctx.env.IMAGE_SETTINGS.max_width;
31 const bskyImageLimits = `Max file size of ${BSKY_IMG_SIZE_LIMIT_IN_MB}MB`;
32 return (
33 <section>
···64 <li><span data-tooltip={BSKY_IMG_FILE_EXTS}>Images</span>:
65 <ul>
66 <li>must be less than {CF_IMAGES_MAX_DIMENSION}x{CF_IMAGES_MAX_DIMENSION} pixels</li>
67+ <li>must have a file size smaller than {CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB ({APP_NAME} will attempt to compress images to fit <span data-tooltip={bskyImageLimits}>BlueSky's requirements</span>)
68+ {maxWidth ?
69+ <ol>
70+ <li>images over {BSKY_IMG_SIZE_LIMIT_IN_MB}MB with a width greater than <b>{maxWidth}px</b> will also <u data-tooltip="will preserve aspect ratio">be resized</u> in addition to being compressed</li>
71+ </ol> : null}
72+ </li>
73+74 <li>thumbnails will only be shown here for images that are smaller than {MAX_THUMBNAIL_SIZE}MB</li>
75+ <li>if an image fails to upload, you'll need to manually adjust the file to fit it properly</li>
76 </ul></li>
77 <li><span data-tooltip={BSKY_VIDEO_FILE_EXTS}>Videos</span>:
78 <ul>
79 <li>must be shorter than {BSKY_VIDEO_MAX_DURATION} minutes</li>
80 <li>must be smaller than {R2_FILE_SIZE_LIMIT_IN_MB}MB</li>
81+ <li>will be processed on your PDS after they're posted. This may show a temporary <i>"Video not Found"</i> message for a bit after posting.</li>
82 </ul></li>
83 </ul></small></div>
84 </footer>
···102 <section>
103 <article>
104 <header>Insert Post/Feed/List Link</header>
105+ <input id="recordBox" placeholder="https://" title="Must be a link to a ATProto based record" />
106 <small>Posts must be quotable and all record types must exist upon the scheduled time. If it does not exist, it will not be attached to your post.</small>
107 </article>
108 </section>
+2-2
src/layout/settings.tsx
···38 <label>
39 BSky App Password:
40 <BSkyAppPasswordField />
41- <small>If you need to change your bsky application password, you can
42- <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small>
43 </label>
44 <label>
45 BSky PDS:
···38 <label>
39 BSky App Password:
40 <BSkyAppPasswordField />
41+ <small>If you need to change your bsky application password, you can
42+ <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small>
43 </label>
44 <label>
45 BSky PDS:
···1// Change this value to break out of any caching that might be happening
2// for the runtime scripts (ex: main.js & postHelper.js)
3-export const CURRENT_SCRIPT_VERSION: string = "1.5.3";
45export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`;
6
···1// Change this value to break out of any caching that might be happening
2// for the runtime scripts (ex: main.js & postHelper.js)
3+export const CURRENT_SCRIPT_VERSION: string = "1.5.4";
45export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`;
6
+19-7
src/utils/r2Query.ts
···108 let failedToResize = true;
109110 if (c.env.IMAGE_SETTINGS.enabled) {
0111 const resizeFilename = uuidv4();
112 const resizeBucketPush = await c.env.R2RESIZE.put(resizeFilename, await file.bytes(), {
113 customMetadata: {"user": userId },
···116117 if (!resizeBucketPush) {
118 console.error(`Failed to push ${file.name} to the resizing bucket`);
119- return {"success": false, "error": "unable to handle image for resize, please make it smaller"}
120 }
121122- // TODO: use the image wrangler binding
000000000000123 for (var i = 0; i < c.env.IMAGE_SETTINGS.steps.length; ++i) {
124- const qualityLevel = c.env.IMAGE_SETTINGS.steps[i];
125 const response = await fetch(new URL(resizeFilename, c.env.IMAGE_SETTINGS.bucket_url!), {
126 headers: {
127 "x-skyscheduler-helper": c.env.RESIZE_SECRET_HEADER
···129 cf: {
130 image: {
131 quality: qualityLevel,
132- metadata: "copyright",
133- format: "jpeg"
134 }
135 }
136 });
···138 const resizedHeader = response.headers.get("Cf-Resized");
139 const returnType = response.headers.get("Content-Type") || "";
140 const transformFileSize: number = Number(response.headers.get("Content-Length")) || 0;
141- const resizeHadError = resizedHeader === null || resizedHeader.indexOf("err=") !== -1;
142143 if (!resizeHadError && BSKY_IMG_MIME_TYPES.includes(returnType)) {
144 console.log(`Attempting quality level ${qualityLevel}% for ${originalName}, size: ${transformFileSize}`);
···174175 if (failedToResize) {
176 const fileSizeOverAmount: string = ((file.size - BSKY_IMG_SIZE_LIMIT)/MB_TO_BYTES).toFixed(2);
177- return {"success": false, "originalName": originalName, "error": `Image is too large for bsky, over by ${fileSizeOverAmount}MB`};
178 }
179 }
180
···108 let failedToResize = true;
109110 if (c.env.IMAGE_SETTINGS.enabled) {
111+ // Randomly generated id to be used during the resize process
112 const resizeFilename = uuidv4();
113 const resizeBucketPush = await c.env.R2RESIZE.put(resizeFilename, await file.bytes(), {
114 customMetadata: {"user": userId },
···117118 if (!resizeBucketPush) {
119 console.error(`Failed to push ${file.name} to the resizing bucket`);
120+ return {"success": false, "error": "resize process ran out of disk space, you'll need to resize the image or try again"};
121 }
122123+ // Default image rules for resizing an image
124+ const imageRules: RequestInitCfPropertiesImage = {
125+ fit: "scale-down",
126+ metadata: "copyright",
127+ format: "jpeg",
128+ };
129+130+ // if the application is to also resize the width automatically, do so here.
131+ // this will preserve aspect ratio
132+ if (c.env.IMAGE_SETTINGS.max_width) {
133+ imageRules.width = c.env.IMAGE_SETTINGS.max_width;
134+ }
135+136 for (var i = 0; i < c.env.IMAGE_SETTINGS.steps.length; ++i) {
137+ const qualityLevel: number = c.env.IMAGE_SETTINGS.steps[i];
138 const response = await fetch(new URL(resizeFilename, c.env.IMAGE_SETTINGS.bucket_url!), {
139 headers: {
140 "x-skyscheduler-helper": c.env.RESIZE_SECRET_HEADER
···142 cf: {
143 image: {
144 quality: qualityLevel,
145+ ...imageRules
0146 }
147 }
148 });
···150 const resizedHeader = response.headers.get("Cf-Resized");
151 const returnType = response.headers.get("Content-Type") || "";
152 const transformFileSize: number = Number(response.headers.get("Content-Length")) || 0;
153+ const resizeHadError: boolean = (resizedHeader === null || resizedHeader.indexOf("err=") !== -1);
154155 if (!resizeHadError && BSKY_IMG_MIME_TYPES.includes(returnType)) {
156 console.log(`Attempting quality level ${qualityLevel}% for ${originalName}, size: ${transformFileSize}`);
···186187 if (failedToResize) {
188 const fileSizeOverAmount: string = ((file.size - BSKY_IMG_SIZE_LIMIT)/MB_TO_BYTES).toFixed(2);
189+ return {"success": false, "originalName": originalName, "error": `Image is too large for BSky, size is over by ${fileSizeOverAmount}MB`};
190 }
191 }
192
+2-1
wrangler.toml
···79queue = "skyscheduler-repost-queue"
80max_retries = 3
81082[images]
83binding = "IMAGES"
84···90BETTER_AUTH_URL="https://skyscheduler.work"
9192# If we should use cf image transforms & how much of a quality percentage should we try and the public location of the resize bucket.
93-IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/"}
9495# Signup options and if keys should be used
96SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="", invite_uses=10}
···79queue = "skyscheduler-repost-queue"
80max_retries = 3
8182+# used for resizing the thumbnails for link posts
83[images]
84binding = "IMAGES"
85···91BETTER_AUTH_URL="https://skyscheduler.work"
9293# If we should use cf image transforms & how much of a quality percentage should we try and the public location of the resize bucket.
94+IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/", max_width=3000}
9596# Signup options and if keys should be used
97SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="", invite_uses=10}