Files
fluxer/packages/api/src/unfurler/resolvers/bluesky/BlueskyTextFormatter.tsx
2026-02-17 12:22:36 +00:00

126 lines
4.4 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppBskyRichtextFacet, RichText} from '@atproto/api';
import {Logger} from '@fluxer/api/src/Logger';
import type {
BlueskyAuthor,
BlueskyPost,
BlueskyPostThread,
Facet,
ReplyContext,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
export class BlueskyTextFormatter {
embedLinksInText(text: string, facets?: Array<Facet>): string {
if (!facets) return text;
const richText = new RichText({text, facets});
let result = '';
let lastIndex = 0;
const utf8ToUtf16Map = new Map<number, number>();
for (let i = 0; i < text.length; i++) {
const utf8Index = richText.unicodeText.utf16IndexToUtf8Index(i);
utf8ToUtf16Map.set(utf8Index, i);
}
const sortedFacets = [...(richText.facets || [])].sort((a, b) => a.index.byteStart - b.index.byteStart);
for (const facet of sortedFacets) {
const startUtf16 = utf8ToUtf16Map.get(facet.index.byteStart) ?? lastIndex;
const endUtf16 = utf8ToUtf16Map.get(facet.index.byteEnd) ?? text.length;
result += text.slice(lastIndex, startUtf16);
const facetText = text.slice(startUtf16, endUtf16);
const feature = facet.features[0];
if (AppBskyRichtextFacet.isLink(feature)) {
result += `[${this.getLinkDisplayText(feature.uri)}](${feature.uri})`;
} else if (AppBskyRichtextFacet.isMention(feature)) {
result += `[${facetText}](https://bsky.app/profile/${feature.did})`;
} else if (AppBskyRichtextFacet.isTag(feature)) {
const tagText = facetText.startsWith('#') ? facetText.slice(1) : facetText;
result += `[${facetText}](https://bsky.app/search?q=%23${encodeURIComponent(tagText)})`;
} else {
result += facetText;
}
lastIndex = endUtf16;
}
result += text.slice(lastIndex);
return result;
}
getLinkDisplayText(uri: string): string {
const url = new URL(uri);
const hostname = url.hostname;
const path = url.pathname;
const trimmedPath = path.replace(/^\/+|\/+$/g, '');
if (!trimmedPath) {
return `${hostname}/`;
}
const truncatedPath = trimmedPath.length > 12 ? `${trimmedPath.slice(0, 12)}...` : trimmedPath;
return `${hostname}/${truncatedPath}`;
}
formatAuthor(author: BlueskyAuthor): string {
const displayName = author.displayName || author.handle;
const handle = author.handle;
const profileUrl = `https://bsky.app/profile/${handle}`;
return `**[${displayName} (@${handle})](${profileUrl})**`;
}
formatPostContent(post: BlueskyPost, thread: BlueskyPostThread): string {
let processedText = this.embedLinksInText(post.record.text, post.record.facets);
const replyContext = this.extractReplyContext(post, thread);
if (replyContext) {
processedText = `-# ↩ [${replyContext.authorName} (@${replyContext.authorHandle})](${replyContext.postUrl})\n${processedText}`;
Logger.debug(
{postUri: post.uri, replyingTo: replyContext.authorName, replyingToHandle: replyContext.authorHandle},
'Added reply indicator to post content',
);
}
return processedText;
}
extractReplyContext(post: BlueskyPost, thread: BlueskyPostThread): ReplyContext | null {
if (!post.record.reply) {
Logger.debug({postUri: post.uri}, 'Post is not a reply');
return null;
}
if (thread.thread.parent?.post) {
const parentPost = thread.thread.parent.post;
const authorName = parentPost.author.displayName || parentPost.author.handle;
const authorHandle = parentPost.author.handle;
const postUrl = `https://bsky.app/profile/${authorHandle}/post/${parentPost.uri.split('/').pop()}`;
Logger.debug({parentAuthor: authorName, parentHandle: authorHandle, postUrl}, 'Found parent post in thread data');
return {authorName, authorHandle, postUrl};
}
Logger.debug({postUri: post.uri}, 'Parent post not found in thread data');
return null;
}
}