From c4f4ee3bdee2c4ca4f3cc960d2a83d95f6e2ba03 Mon Sep 17 00:00:00 2001 From: Xenoyia Date: Sun, 24 Sep 2023 14:30:41 +0100 Subject: [PATCH 1/6] Furtastic: Fixed markdown parsing. (#246) * Furtastic: Fixed broken imports. * Furtastic: Fixed markdown parsing. --------- Co-authored-by: Lemonynade --- .../websites/furtastic/furtastic.service.ts | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/electron-app/src/server/websites/furtastic/furtastic.service.ts b/electron-app/src/server/websites/furtastic/furtastic.service.ts index 04b6c029..95388feb 100644 --- a/electron-app/src/server/websites/furtastic/furtastic.service.ts +++ b/electron-app/src/server/websites/furtastic/furtastic.service.ts @@ -12,8 +12,6 @@ import { SubmissionRating, } from 'postybirb-commons'; import UserAccountEntity from 'src/server//account/models/user-account.entity'; -import { UsernameParser } from 'src/server/description-parsing/miscellaneous/username.parser'; -import { PlaintextParser } from 'src/server/description-parsing/plaintext/plaintext.parser'; import ImageManipulator from 'src/server/file-manipulation/manipulators/image.manipulator'; import Http from 'src/server/http/http.util'; import { CancellationToken } from 'src/server/submission/post/cancellation/cancellation-token'; @@ -25,13 +23,13 @@ import WebsiteValidator from 'src/server/utils/website-validator.util'; import { LoginResponse } from '../interfaces/login-response.interface'; import { ScalingOptions } from '../interfaces/scaling-options.interface'; import { Website } from '../website.base'; +import { MarkdownParser } from 'src/server/description-parsing/markdown/markdown.parser'; @Injectable() export class Furtastic extends Website { readonly BASE_URL: string = 'https://api.furtastic.art'; readonly acceptsFiles: string[] = ['jpeg', 'jpg', 'png', 'gif', 'webm']; - readonly acceptsSourceUrls: boolean = true; - readonly defaultDescriptionParser = PlaintextParser.parse; + readonly defaultDescriptionParser = MarkdownParser.parse; readonly enableAdvertisement: boolean = true; readonly acceptsAdditionalFiles: boolean = true; @@ -55,34 +53,6 @@ export class Furtastic extends Website { getScalingOptions(file: FileRecord): ScalingOptions { return { maxSize: FileSize.MBtoBytes(100) }; } - - preparseDescription(text: string) { - return UsernameParser.replaceText(text, 'ft', '@$1').replace( - /(.*?)<\/a>/gi, - '"$4":$2', - ); - } - - parseDescription(text: string) { - text = text.replace(//gi, '**'); - text = text.replace(//gi, '*'); - text = text.replace(//gi, ''); - text = text.replace(//gi, '~~'); - text = text.replace(/<\/b>/gi, '**'); - text = text.replace(/<\/i>/gi, '*'); - text = text.replace(/<\/u>/gi, ''); - text = text.replace(/<\/s>/gi, '~~'); - text = text.replace(//gi, '*'); - text = text.replace(/<\/em>/gi, '*'); - text = text.replace(//gi, '**'); - text = text.replace(/<\/strong>/gi, '**'); - text = text.replace( - /((.|\n)*?)<\/span>/gim, - '$2', - ); - text = text.replace(/(.*?)<\/a>/gi, '[$4]($2)'); - return super.parseDescription(text); - } async postFileSubmission( cancellationToken: CancellationToken, From f2f995ffdfb804b85058fdeb9692eb252fc2543a Mon Sep 17 00:00:00 2001 From: askmeaboutlo0m Date: Wed, 4 Oct 2023 12:30:06 +0200 Subject: [PATCH 2/6] Don't prepend space to first appended tag (#250) * Don't prepend space to first appended tag Appending tags already puts two newlines at the end of the description, there's no need for another space. I think there was also an off-by-one in the length check here, allowing one less character than the limit, which is now rectified. * Don't prefix Bluesky tags with space either Adds some special handling to the appendTags method that allows the Bluesky integration to use its rich text grapheme counting thingy to check if the description fits or not. This also purges a whole pile of duplicate tag appendage code. --- .../websites/bluesky/bluesky.service.ts | 56 ++++--------------- .../src/server/websites/website.base.ts | 32 +++++++---- 2 files changed, 32 insertions(+), 56 deletions(-) diff --git a/electron-app/src/server/websites/bluesky/bluesky.service.ts b/electron-app/src/server/websites/bluesky/bluesky.service.ts index 36e2b55c..894f5259 100644 --- a/electron-app/src/server/websites/bluesky/bluesky.service.ts +++ b/electron-app/src/server/websites/bluesky/bluesky.service.ts @@ -111,6 +111,10 @@ async function fetchHandler( // End of Polyfill +function getRichTextLength(text: string): number { + return new RichText({text}).graphemeLength; +} + @Injectable() export class Bluesky extends Website { readonly BASE_URL = ''; @@ -165,6 +169,10 @@ export class Bluesky extends Website { ).map(tag => `#${tag}`); } + private appendRichTextTags(tags: string[], description: string): string { + return this.appendTags(this.formatTags(tags), description, this.MAX_CHARS, getRichTextLength); + } + private async uploadMedia( agent: BskyAgent, data: BlueskyAccountData, @@ -215,29 +223,7 @@ export class Bluesky extends Website { $type: 'app.bsky.embed.images', }; - let status = data.description; - let r = new RichText({text: status}); - - const tags = this.formatTags(data.tags); - - // Update the post content with the Tags if any are specified - for BlueSky (There is no tagging engine yet), we need to append - // these onto the post, *IF* there is character count available. - if (tags.length > 0) { - status += '\n\n'; - } - - tags.forEach(tag => { - let remain = this.MAX_CHARS - status.length; - let tagToInsert = tag; - if (!tag.startsWith('#')) { - tagToInsert = `#${tagToInsert}`; - } - if (remain > tagToInsert.length) { - status += ` ${tagToInsert}`; - } - // We don't exit the loop, so we can cram in every possible tag, even if there are short ones! - r = new RichText({text: status}); - }); + const status = this.appendRichTextTags(data.tags, data.description); let labelsRecord: ComAtprotoLabelDefs.SelfLabels | undefined; if (data.options.label_rating) { @@ -284,29 +270,7 @@ export class Bluesky extends Website { password: accountData.password, }); - let status = data.description; - let r = new RichText({text: status}); - - const tags = this.formatTags(data.tags); - - // Update the post content with the Tags if any are specified - for BlueSky (There is no tagging engine yet), we need to append - // these onto the post, *IF* there is character count available. - if (tags.length > 0) { - status += '\n\n'; - } - - tags.forEach(tag => { - let remain = this.MAX_CHARS - r.graphemeLength; - let tagToInsert = tag; - if (!tag.startsWith('#')) { - tagToInsert = `#${tagToInsert}`; - } - if (remain > (tagToInsert.length)) { - status += ` ${tagToInsert}`; - } - // We don't exit the loop, so we can cram in every possible tag, even if there are short ones! - r = new RichText({text: status}); - }); + const status = this.appendRichTextTags(data.tags, data.description); let labelsRecord: ComAtprotoLabelDefs.SelfLabels | undefined; if (data.options.label_rating) { diff --git a/electron-app/src/server/websites/website.base.ts b/electron-app/src/server/websites/website.base.ts index 86cca678..9a5d91ca 100644 --- a/electron-app/src/server/websites/website.base.ts +++ b/electron-app/src/server/websites/website.base.ts @@ -133,19 +133,31 @@ export abstract class Website { /** * Appends the tags to the description if there is enough character count available. */ - appendTags(tags: string[], description: string, limit: number) { - if (!tags.length || description.length + 4 > limit) return description; - - description += '\n\n'; + appendTags( + tags: string[], + description: string, + limit: number, + getLength: (text: string) => number = (text) => text.length, + ): string { + const appendedTags = []; + const appendToDescription = function (tag?: string): string { + const suffix = tag ? [...appendedTags, tag] : appendedTags; + if (suffix.length === 0) { + return description; + } else { + return description + '\n\n' + suffix.join(' '); + } + }; - tags.forEach(tag => { - if (description.length + 1 + tag.length < limit) { - description += ` ${tag}`; + for (const tag of tags) { + if (getLength(appendToDescription(tag)) <= limit) { + appendedTags.push(tag); } - // We don't exit the loop, so we can cram in every possible tag, even if there are short ones! - }); + // Keep looping over all tags even if one of them doesn't fit, we might + // find one that's short enough to cram in still. + } - return description; + return appendToDescription(); } parseDescription(text: string, type?: SubmissionType): string { From 947d36027d87b4ae76af8167be75b8e201c54be2 Mon Sep 17 00:00:00 2001 From: Paul Friederichsen Date: Wed, 4 Oct 2023 05:31:57 -0500 Subject: [PATCH 3/6] Fix scaling and limits for Mastodon and Pleroma/Akkoma (#247) * Fix scaling and limits for Mastodon and Pleroma/Akkoma - The max width and height are now calculated based on the maximum number of pixels provided by the instance. - max post length is now set by the max characters setting from the instance. - Adds support for a max filesize and max post length from Pleroma and Akkoma instances. - The chunk size is now determined by the max media attachments setting from Mastodon and Pleroma instances - Updates the accepted filetypes based on what standard Mastodon supports. Ideally this would be from the list of mime types provided by the instance though. * Fix formatting --- .../websites/mastodon/mastodon.service.ts | 99 +++++++++++++------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/electron-app/src/server/websites/mastodon/mastodon.service.ts b/electron-app/src/server/websites/mastodon/mastodon.service.ts index 55f52516..1a95d7b0 100644 --- a/electron-app/src/server/websites/mastodon/mastodon.service.ts +++ b/electron-app/src/server/websites/mastodon/mastodon.service.ts @@ -38,16 +38,21 @@ const INFO_KEY = 'INSTANCE INFO'; type MastodonInstanceInfo = { configuration: { - statuses: { + statuses?: { max_characters: number; max_media_attachments: number; }; - media_attachments: { + media_attachments?: { supported_mime_types: string[]; image_size_limit: number; video_size_limit: number; + image_matrix_limit: number; + video_matrix_limit: number; }; }; + upload_limit?: number; // Pleroma, Akkoma + max_toot_chars?: number; // Pleroma, Akkoma + max_media_attachments?: number; //Pleroma }; @Injectable() @@ -64,13 +69,26 @@ export class Mastodon extends Website { 'jpeg', 'jpg', 'gif', - 'swf', - 'flv', + 'webp', + 'avif', + 'heic', + 'heif', 'mp4', + 'webm', + 'm4v', + 'mov', 'doc', 'rtf', 'txt', 'mp3', + 'wav', + 'ogg', + 'oga', + 'opus', + 'aac', + 'm4a', + '3gp', + 'wma', ]; async checkLoginStatus(data: UserAccountEntity): Promise { @@ -94,20 +112,27 @@ export class Mastodon extends Website { getScalingOptions(file: FileRecord, accountId: string): ScalingOptions { const instanceInfo: MastodonInstanceInfo = this.getAccountInfo(accountId, INFO_KEY); - return instanceInfo?.configuration?.media_attachments - ? { - maxHeight: 4000, - maxWidth: 4000, - maxSize: - file.type === FileSubmissionType.IMAGE - ? instanceInfo.configuration.media_attachments.image_size_limit - : instanceInfo.configuration.media_attachments.video_size_limit, - } - : { - maxHeight: 4000, - maxWidth: 4000, - maxSize: FileSize.MBtoBytes(300), - }; + if (instanceInfo?.configuration?.media_attachments) { + const maxPixels = + file.type === FileSubmissionType.IMAGE + ? instanceInfo.configuration.media_attachments.image_matrix_limit + : instanceInfo.configuration.media_attachments.video_matrix_limit; + + return { + maxHeight: Math.round(Math.sqrt(maxPixels * (file.width / file.height))), + maxWidth: Math.round(Math.sqrt(maxPixels * (file.height / file.width))), + maxSize: + file.type === FileSubmissionType.IMAGE + ? instanceInfo.configuration.media_attachments.image_size_limit + : instanceInfo.configuration.media_attachments.video_size_limit, + }; + } else if (instanceInfo?.upload_limit) { + return { + maxSize: instanceInfo?.upload_limit, + }; + } else { + return undefined; + } } // Megaladon api has uploadMedia method, hovewer, it does not work with mastodon @@ -183,8 +208,12 @@ export class Mastodon extends Website { } const instanceInfo: MastodonInstanceInfo = this.getAccountInfo(data.part.accountId, INFO_KEY); - const chunkCount = instanceInfo?.configuration?.statuses?.max_media_attachments ?? 4; - const maxChars = instanceInfo?.configuration?.statuses?.max_characters ?? 500; + const chunkCount = + instanceInfo?.configuration?.statuses?.max_media_attachments ?? + instanceInfo?.max_media_attachments ?? + (instanceInfo?.upload_limit ? 1000 : 4); + const maxChars = + instanceInfo?.configuration?.statuses?.max_characters ?? instanceInfo?.max_toot_chars ?? 500; const isSensitive = data.rating !== SubmissionRating.GENERAL; const chunks = _.chunk(uploadedMedias, chunkCount); @@ -297,7 +326,7 @@ export class Mastodon extends Website { if (description.length > maxChars) { warnings.push( - `Max description length allowed is ${maxChars} characters (for this Mastodon client).`, + `Max description length allowed is ${maxChars} characters (for this instance).`, ); } @@ -308,35 +337,40 @@ export class Mastodon extends Website { ), ]; - const maxImageSize = - instanceInfo?.configuration?.media_attachments?.image_size_limit ?? FileSize.MBtoBytes(50); - files.forEach(file => { const { type, size, name, mimetype } = file; if (!WebsiteValidator.supportsFileType(file, this.acceptsFiles)) { problems.push(`Does not support file format: (${name}) ${mimetype}.`); } - if (maxImageSize < size) { + const scalingOptions = this.getScalingOptions(file, submissionPart.accountId); + + if (scalingOptions && scalingOptions.maxSize < size) { if ( isAutoscaling && type === FileSubmissionType.IMAGE && ImageManipulator.isMimeType(mimetype) ) { - warnings.push(`${name} will be scaled down to ${FileSize.BytesToMB(maxImageSize)}MB`); + warnings.push( + `${name} will be scaled down to ${FileSize.BytesToMB(scalingOptions.maxSize)}MB`, + ); } else { - problems.push(`Mastodon limits ${mimetype} to ${FileSize.BytesToMB(maxImageSize)}MB`); + problems.push( + `This instance limits ${mimetype} to ${FileSize.BytesToMB(scalingOptions.maxSize)}MB`, + ); } } - // Check the image dimensions are not over 4000 x 4000 - this is the Mastodon server max if ( + scalingOptions && isAutoscaling && type === FileSubmissionType.IMAGE && - (file.height > 4000 || file.width > 4000) + scalingOptions.maxWidth && + scalingOptions.maxHeight && + (file.height > scalingOptions.maxHeight || file.width > scalingOptions.maxWidth) ) { warnings.push( - `${name} will be scaled down to a maximum size of 4000x4000, while maintaining aspect ratio`, + `${name} will be scaled down to a maximum size of ${scalingOptions.maxWidth}x${scalingOptions.maxHeight}, while maintaining aspect ratio`, ); } }); @@ -367,10 +401,11 @@ export class Mastodon extends Website { submissionPart.accountId, INFO_KEY, ); - const maxChars = instanceInfo?.configuration?.statuses?.max_characters ?? 500; + const maxChars = + instanceInfo?.configuration?.statuses?.max_characters ?? instanceInfo?.max_toot_chars ?? 500; if (description.length > maxChars) { warnings.push( - `Max description length allowed is ${maxChars} characters (for this Mastodon client).`, + `Max description length allowed is ${maxChars} characters (for this instance).`, ); } From 29c7de940b8b7b48b6f422cb1b44e88370633bf6 Mon Sep 17 00:00:00 2001 From: askmeaboutlo0m Date: Wed, 4 Oct 2023 12:35:44 +0200 Subject: [PATCH 4/6] Replies for Mastodon and Bluesky (#249) * Allow specifying reply to URL for Mastodon posts Because I wanna be able to post threads. This interface isn't too comfortable, since it requires manual copying of the parent post URL. But letting your wire up a parent post from within PostyBirb is left as an exercise for someone else or future me, this is currently already useful as a minimum viable product. * Allow specifying reply to URL for Bluesky posts Analogous to the Mastodon implementation in the previous commit, except more complicated because Bluesky. --- .../bluesky/bluesky.file.options.interface.ts | 1 + .../bluesky.notification.options.interface.ts | 1 + .../mastodon.file.options.interface.ts | 1 + ...mastodon.notification.options.interface.ts | 1 + .../websites/bluesky/bluesky.file.options.ts | 5 ++ .../bluesky/bluesky.notification.options.ts | 5 ++ .../mastodon/mastodon.file.options.ts | 5 ++ .../mastodon/mastodon.notification.options.ts | 5 ++ .../websites/bluesky/bluesky.service.ts | 54 ++++++++++++++++++- .../websites/mastodon/mastodon.service.ts | 29 +++++++++- ui/src/websites/bluesky/Bluesky.tsx | 10 +++- ui/src/websites/mastodon/Mastodon.tsx | 10 +++- 12 files changed, 121 insertions(+), 6 deletions(-) diff --git a/commons/src/interfaces/websites/bluesky/bluesky.file.options.interface.ts b/commons/src/interfaces/websites/bluesky/bluesky.file.options.interface.ts index 7fb2bfb8..61cb9cc7 100644 --- a/commons/src/interfaces/websites/bluesky/bluesky.file.options.interface.ts +++ b/commons/src/interfaces/websites/bluesky/bluesky.file.options.interface.ts @@ -3,4 +3,5 @@ import { DefaultFileOptions } from '../../submission/default-options.interface'; export interface BlueskyFileOptions extends DefaultFileOptions { altText?: string; label_rating: string; + replyToUrl?: string; } diff --git a/commons/src/interfaces/websites/bluesky/bluesky.notification.options.interface.ts b/commons/src/interfaces/websites/bluesky/bluesky.notification.options.interface.ts index 97132404..b78366d5 100644 --- a/commons/src/interfaces/websites/bluesky/bluesky.notification.options.interface.ts +++ b/commons/src/interfaces/websites/bluesky/bluesky.notification.options.interface.ts @@ -2,4 +2,5 @@ import { DefaultOptions } from '../../submission/default-options.interface'; export interface BlueskyNotificationOptions extends DefaultOptions { label_rating: string; + replyToUrl?: string; } diff --git a/commons/src/interfaces/websites/mastodon/mastodon.file.options.interface.ts b/commons/src/interfaces/websites/mastodon/mastodon.file.options.interface.ts index 8c980534..ecbe86a1 100644 --- a/commons/src/interfaces/websites/mastodon/mastodon.file.options.interface.ts +++ b/commons/src/interfaces/websites/mastodon/mastodon.file.options.interface.ts @@ -5,4 +5,5 @@ export interface MastodonFileOptions extends DefaultFileOptions { spoilerText?: string; visibility: string; altText?: string; + replyToUrl?: string; } diff --git a/commons/src/interfaces/websites/mastodon/mastodon.notification.options.interface.ts b/commons/src/interfaces/websites/mastodon/mastodon.notification.options.interface.ts index f730e151..610d083f 100644 --- a/commons/src/interfaces/websites/mastodon/mastodon.notification.options.interface.ts +++ b/commons/src/interfaces/websites/mastodon/mastodon.notification.options.interface.ts @@ -4,4 +4,5 @@ export interface MastodonNotificationOptions extends DefaultOptions { useTitle: boolean; spoilerText?: string; visibility: string; + replyToUrl?: string; } diff --git a/commons/src/websites/bluesky/bluesky.file.options.ts b/commons/src/websites/bluesky/bluesky.file.options.ts index d65bfe89..f628b6b4 100644 --- a/commons/src/websites/bluesky/bluesky.file.options.ts +++ b/commons/src/websites/bluesky/bluesky.file.options.ts @@ -19,6 +19,11 @@ export class BlueskyFileOptionsEntity @DefaultValue('') label_rating: string = ''; + @Expose() + @IsOptional() + @IsString() + replyToUrl?: string; + constructor(entity?: Partial) { super(entity as DefaultFileOptions); } diff --git a/commons/src/websites/bluesky/bluesky.notification.options.ts b/commons/src/websites/bluesky/bluesky.notification.options.ts index 917f147c..bbdefcf6 100644 --- a/commons/src/websites/bluesky/bluesky.notification.options.ts +++ b/commons/src/websites/bluesky/bluesky.notification.options.ts @@ -14,6 +14,11 @@ export class BlueskyNotificationOptionsEntity @DefaultValue('') label_rating: string = ''; + @Expose() + @IsOptional() + @IsString() + replyToUrl?: string; + constructor(entity?: Partial) { super(entity as DefaultOptions); } diff --git a/commons/src/websites/mastodon/mastodon.file.options.ts b/commons/src/websites/mastodon/mastodon.file.options.ts index 0f23bec0..55eef672 100644 --- a/commons/src/websites/mastodon/mastodon.file.options.ts +++ b/commons/src/websites/mastodon/mastodon.file.options.ts @@ -29,6 +29,11 @@ export class MastodonFileOptionsEntity @IsString() altText?: string; + @Expose() + @IsOptional() + @IsString() + replyToUrl?: string; + constructor(entity?: Partial) { super(entity as DefaultFileOptions); } diff --git a/commons/src/websites/mastodon/mastodon.notification.options.ts b/commons/src/websites/mastodon/mastodon.notification.options.ts index fae33cc8..485f3ba2 100644 --- a/commons/src/websites/mastodon/mastodon.notification.options.ts +++ b/commons/src/websites/mastodon/mastodon.notification.options.ts @@ -24,6 +24,11 @@ export class MastodonNotificationOptionsEntity @DefaultValue('public') visibility!: string; + @Expose() + @IsOptional() + @IsString() + replyToUrl?: string; + constructor(entity?: Partial) { super(entity as DefaultOptions); } diff --git a/electron-app/src/server/websites/bluesky/bluesky.service.ts b/electron-app/src/server/websites/bluesky/bluesky.service.ts index 894f5259..67f003a2 100644 --- a/electron-app/src/server/websites/bluesky/bluesky.service.ts +++ b/electron-app/src/server/websites/bluesky/bluesky.service.ts @@ -29,6 +29,7 @@ import { BskyAgent, stringifyLex, jsonToLex, AppBskyEmbedImages, AppBskyRichtex import { PlaintextParser } from 'src/server/description-parsing/plaintext/plaintext.parser'; import fetch from "node-fetch"; import Graphemer from 'graphemer'; +import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; // Start of Polyfill @@ -205,6 +206,8 @@ export class Bluesky extends Website { password: accountData.password, }); + const reply = await this.getReplyRef(agent, data.options.replyToUrl); + const files = [data.primary, ...data.additional]; let uploadedMedias: AppBskyEmbedImages.Image[] = []; let fileCount = 0; @@ -242,6 +245,7 @@ export class Bluesky extends Website { facets: rt.facets, embed: embeds, labels: labelsRecord, + ...(reply ? { reply } : {}), }) .catch(err => { return Promise.reject(this.createPostResponse({ message: err })); @@ -270,6 +274,7 @@ export class Bluesky extends Website { password: accountData.password, }); + const reply = await this.getReplyRef(agent, data.options.replyToUrl); const status = this.appendRichTextTags(data.tags, data.description); let labelsRecord: ComAtprotoLabelDefs.SelfLabels | undefined; @@ -286,7 +291,8 @@ export class Bluesky extends Website { let postResult = await agent.post({ text: rt.text, facets: rt.facets, - labels: labelsRecord + labels: labelsRecord, + ...(reply ? { reply } : {}), }).catch(err => { return Promise.reject( this.createPostResponse({ message: err }), @@ -367,6 +373,8 @@ export class Bluesky extends Website { ); } + this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + return { problems, warnings }; } @@ -388,6 +396,50 @@ export class Bluesky extends Website { ); } + this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + return { problems, warnings }; } + + private validateReplyToUrl(problems: string[], url?: string): void { + if(url?.trim() && !this.getPostIdFromUrl(url)) { + problems.push("Invalid post URL to reply to."); + } + } + + private async getReplyRef(agent: BskyAgent, url?: string): Promise { + if (!url?.trim()) { + return null; + } + + const postId = this.getPostIdFromUrl(url); + if (!postId) { + throw new Error(`Invalid reply to url '${url}'`); + } + + // cf. https://atproto.com/blog/create-post#replies + const parent = await agent.getPost(postId); + const reply = parent.value.reply; + const root = reply ? reply.root : parent; + return { + root: { uri: root.uri, cid: root.cid }, + parent: { uri: parent.uri, cid: parent.cid }, + }; + } + + private getPostIdFromUrl(url: string): { repo: string; rkey: string } | null { + // A regular web link like https://bsky.app/profile/{repo}/post/{id} + const link = /\/profile\/([^\/]+)\/post\/([a-zA-Z0-9\.\-_~]+)/.exec(url); + if (link) { + return { repo: link[1], rkey: link[2] }; + } + + // Protocol link like at://did:plc:{repo}/app.bsky.feed.post/{id} + const at = /(did:plc:[a-zA-Z0-9\.\-_~]+)\/.+\.post\/([a-zA-Z0-9\.\-_~]+)/.exec(url); + if (at) { + return { repo: at[1], rkey: at[2] }; + } + + return null; + } } diff --git a/electron-app/src/server/websites/mastodon/mastodon.service.ts b/electron-app/src/server/websites/mastodon/mastodon.service.ts index 1a95d7b0..9327dd5b 100644 --- a/electron-app/src/server/websites/mastodon/mastodon.service.ts +++ b/electron-app/src/server/websites/mastodon/mastodon.service.ts @@ -222,6 +222,7 @@ export class Mastodon extends Website { }`.substring(0, maxChars); let lastId = ''; let source = ''; + const replyToId = this.getPostIdFromUrl(data.options.replyToUrl); for (let i = 0; i < chunks.length; i++) { this.checkCancelled(cancellationToken); @@ -233,7 +234,10 @@ export class Mastodon extends Website { if (i !== 0) { statusOptions.in_reply_to_id = lastId; + } else if (replyToId) { + statusOptions.in_reply_to_id = replyToId; } + if (data.options.spoilerText) { statusOptions.spoiler_text = data.options.spoilerText; } @@ -282,6 +286,11 @@ export class Mastodon extends Website { } status = this.appendTags(this.formatTags(data.tags), status, maxChars); + const replyToId = this.getPostIdFromUrl(data.options.replyToUrl); + if (replyToId) { + statusOptions.in_reply_to_id = replyToId; + } + this.checkCancelled(cancellationToken); try { const result = (await M.postStatus(status, statusOptions)).data as Entity.Status; @@ -384,6 +393,8 @@ export class Mastodon extends Website { ); } + this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + return { problems, warnings }; } @@ -392,6 +403,7 @@ export class Mastodon extends Website { submissionPart: SubmissionPart, defaultPart: SubmissionPart, ): ValidationParts { + const problems = []; const warnings = []; const description = this.defaultDescriptionParser( FormContent.getDescription(defaultPart.data.description, submissionPart.data.description), @@ -409,6 +421,21 @@ export class Mastodon extends Website { ); } - return { problems: [], warnings }; + this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + + return { problems, warnings }; + } + + private validateReplyToUrl(problems: string[], url?: string): void { + if(url?.trim() && !this.getPostIdFromUrl(url)) { + problems.push("Invalid post URL to reply to."); + } + } + + private getPostIdFromUrl(url: string): string | null { + // We expect this to a post URL like https://{instance}/@{user}/{id} or + // https://:instance/deck/@{user}/{id}. We grab the id after the @ part. + const match = /\/@[^\/]+\/([0-9]+)/.exec(url); + return match ? match[1] : null; } } diff --git a/ui/src/websites/bluesky/Bluesky.tsx b/ui/src/websites/bluesky/Bluesky.tsx index 03999e70..d8e0277c 100644 --- a/ui/src/websites/bluesky/Bluesky.tsx +++ b/ui/src/websites/bluesky/Bluesky.tsx @@ -62,7 +62,10 @@ BlueskyNotificationOptions Adult: Nudity Adult: Porn - , + , + + + , ); return elements; } @@ -84,13 +87,16 @@ export class BlueskyFileSubmissionForm extends GenericFileSubmissionSectionAdult: Nudity Adult: Porn - , + , , + + + , ); return elements; } diff --git a/ui/src/websites/mastodon/Mastodon.tsx b/ui/src/websites/mastodon/Mastodon.tsx index b6301b43..5ec428f2 100644 --- a/ui/src/websites/mastodon/Mastodon.tsx +++ b/ui/src/websites/mastodon/Mastodon.tsx @@ -90,7 +90,10 @@ class MastodonNotificationSubmissionForm extends GenericSubmissionSection< Followers Only Mentioned Users Only - + , + + + , ); return elements; } @@ -132,7 +135,10 @@ export class MastodonFileSubmissionForm extends GenericFileSubmissionSectionFollowers Only Mentioned Users Only - + , + + + , ); return elements; } From ce1451b361815561f66a77240627cad79de30e93 Mon Sep 17 00:00:00 2001 From: askmeaboutlo0m Date: Sat, 7 Oct 2023 18:12:16 +0200 Subject: [PATCH 5/6] Fix Bluesky description length and alt text validation (#251) * Properly validate Bluesky description length It wasn't using the plain text version of the description, so its character count was inflated by counting the raw HTML. This is rectified now, by using the same kind of code that other services use. The error message also now says "characters" again instead of "graphemes", since graphemes are what humans interpret as characters, even if the computer may see them as a collection of characters. * Require alt text for Bluesky again With an explanation that that's a bug on their side in the hopes that it will stop people reporting it to PostyBirb's side. --- .../websites/bluesky/bluesky.service.ts | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/electron-app/src/server/websites/bluesky/bluesky.service.ts b/electron-app/src/server/websites/bluesky/bluesky.service.ts index 67f003a2..f693efb1 100644 --- a/electron-app/src/server/websites/bluesky/bluesky.service.ts +++ b/electron-app/src/server/websites/bluesky/bluesky.service.ts @@ -30,6 +30,7 @@ import { PlaintextParser } from 'src/server/description-parsing/plaintext/plaint import fetch from "node-fetch"; import Graphemer from 'graphemer'; import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; +import FormContent from 'src/server/utils/form-content.util'; // Start of Polyfill @@ -318,19 +319,14 @@ export class Bluesky extends Website { const isAutoscaling: boolean = submissionPart.data.autoScale; if (!submissionPart.data.altText) { - warnings.push(`Bluesky recommends alt text to be provided`); - } - - const rt = new RichText({ text: submissionPart.data.description.value }); - const agent = new BskyAgent({ service: 'https://bsky.social' }) - rt.detectFacets(agent); - - if (rt.graphemeLength > this.MAX_CHARS) { problems.push( - `Max description length allowed is ${this.MAX_CHARS} graphemes.`, + 'Bluesky currently always requires alt text to be provided, ' + + 'even if your settings say otherwise. This is a bug on their side.', ); } + this.validateDescriptionLength(problems, submissionPart, defaultPart); + const files = [ submission.primary, ...(submission.additional || []).filter( @@ -386,19 +382,30 @@ export class Bluesky extends Website { const problems: string[] = []; const warnings: string[] = []; - const rt = new RichText({ text: submissionPart.data.description.value }); + this.validateDescriptionLength(problems, submissionPart, defaultPart); + this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + + return { problems, warnings }; + } + + private validateDescriptionLength( + problems: string[], + submissionPart: SubmissionPart, + defaultPart: SubmissionPart, + ): void { + const description = this.defaultDescriptionParser( + FormContent.getDescription(defaultPart.data.description, submissionPart.data.description), + ); + + const rt = new RichText({ text: description }); const agent = new BskyAgent({ service: 'https://bsky.social' }) rt.detectFacets(agent); if (rt.graphemeLength > this.MAX_CHARS) { problems.push( - `Max description length allowed is ${this.MAX_CHARS} graphemes.`, + `Max description length allowed is ${this.MAX_CHARS} characters.`, ); } - - this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); - - return { problems, warnings }; } private validateReplyToUrl(problems: string[], url?: string): void { From 402c62dba69971bfee9d853a3c92980095cb97f0 Mon Sep 17 00:00:00 2001 From: Michael DiCarlo Date: Sat, 7 Oct 2023 12:14:13 -0400 Subject: [PATCH 6/6] v3.1.30 --- electron-app/package.json | 2 +- package.json | 2 +- ui/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron-app/package.json b/electron-app/package.json index 7cfb2e55..3b7cd88d 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus", - "version": "3.1.29", + "version": "3.1.30", "description": "(ClientServer) PostyBirb is an application that helps artists post art and other multimedia to multiple websites more quickly.", "main": "dist/main.js", "author": "Michael DiCarlo", diff --git a/package.json b/package.json index 67fa5ce5..ed2ba1c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus", - "version": "3.1.29", + "version": "3.1.30", "description": "PostyBirb is an application that helps artists post art and other multimedia to multiple websites more quickly..", "main": "index.js", "scripts": { diff --git a/ui/package.json b/ui/package.json index 01c9c213..8f4ffc5f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus-ui", - "version": "3.1.29", + "version": "3.1.30", "license": "BSD-3-Clause", "private": true, "Author": "Michael DiCarlo",