From a4dea7f1f1ce547b897484b05fd3e1808e392221 Mon Sep 17 00:00:00 2001 From: askmeaboutloom Date: Mon, 2 Oct 2023 12:35:01 +0200 Subject: [PATCH 1/2] 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. --- .../mastodon.file.options.interface.ts | 1 + ...mastodon.notification.options.interface.ts | 1 + .../mastodon/mastodon.file.options.ts | 5 ++++ .../mastodon/mastodon.notification.options.ts | 5 ++++ .../websites/mastodon/mastodon.service.ts | 29 ++++++++++++++++++- ui/src/websites/mastodon/Mastodon.tsx | 10 +++++-- 6 files changed, 48 insertions(+), 3 deletions(-) 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/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/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/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 beb7bc9cc0237da5b3454ff8a37840dc75b5b5f7 Mon Sep 17 00:00:00 2001 From: askmeaboutloom Date: Mon, 2 Oct 2023 16:21:44 +0200 Subject: [PATCH 2/2] 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 + .../websites/bluesky/bluesky.file.options.ts | 5 ++ .../bluesky/bluesky.notification.options.ts | 5 ++ .../websites/bluesky/bluesky.service.ts | 54 ++++++++++++++++++- ui/src/websites/bluesky/Bluesky.tsx | 10 +++- 6 files changed, 73 insertions(+), 3 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/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/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/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; }