import type { PostData, PostEmbed, QuotedPostData } from '../post';
import type { PostView } from '@atcute/bluesky/types/app/feed/defs';
import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter';
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
interface MentionFeature {
$type: 'app.bsky.richtext.facet#mention';
did: string;
}
interface LinkFeature {
$type: 'app.bsky.richtext.facet#link';
uri: string;
}
interface TagFeature {
$type: 'app.bsky.richtext.facet#tag';
tag: string;
}
type Feature = MentionFeature | LinkFeature | TagFeature;
const renderSegment = (segment: RichtextSegment, baseUrl: string) => {
const { text, features } = segment;
const escaped = escapeHtml(text);
if (!features) {
return `${escaped}`;
}
// segments can have multiple features, use the first one
const feature = features[0] as Feature;
const createLink = (href: string, text: string) => {
return `${text}`;
};
switch (feature.$type) {
case 'app.bsky.richtext.facet#mention':
return createLink(`${baseUrl}/profile/${feature.did}`, escaped);
case 'app.bsky.richtext.facet#link':
return createLink(feature.uri, escaped);
case 'app.bsky.richtext.facet#tag':
return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped);
default:
return `${escaped}`;
}
};
const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl: string) => {
const segments = segmentize(text, facets);
return segments.map((v) => renderSegment(v, baseUrl)).join('');
};
function blueskyEmbedTypeToEmbedType(type: string) {
switch (type) {
case 'app.bsky.embed.external#view':
case 'app.bsky.embed.external':
return 'external';
case 'app.bsky.embed.images#view':
case 'app.bsky.embed.images':
return 'images';
case 'app.bsky.embed.video#view':
case 'app.bsky.embed.video':
return 'video';
case 'app.bsky.embed.record#view':
case 'app.bsky.embed.record':
return 'record';
case 'app.bsky.embed.recordWithMedia#view':
case 'app.bsky.embed.recordWithMedia':
return 'recordWithMedia';
default:
return 'unknown';
}
}
function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null {
if (!recordView?.author) return null;
const id = recordView.uri?.split('/').pop();
const author = recordView.author;
const value = recordView.value as any;
let htmlContent = '';
if (value?.text) {
htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace(
/\n/g,
'
'
);
}
// Convert nested media embeds (skip record embeds to avoid recursion)
let embed: PostEmbed | undefined;
const firstEmbed = recordView.embeds?.[0] as any;
if (firstEmbed) {
const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type);
if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') {
embed = convertEmbed(firstEmbed, baseUrl);
}
}
return {
author: {
displayName: author.displayName || '',
handle: author.handle,
avatar: author.avatar,
href: `${baseUrl}/profile/${author.did}`
},
href: `${baseUrl}/profile/${author.handle}/post/${id}`,
htmlContent,
createdAt: value?.createdAt,
embed
};
}
function convertEmbed(embedView: any, baseUrl: string): PostEmbed {
const type = blueskyEmbedTypeToEmbedType(embedView?.$type);
switch (type) {
case 'images':
return {
type: 'images',
images: embedView.images?.map((image: any) => ({
alt: image.alt,
thumb: image.thumb,
aspectRatio: image.aspectRatio,
fullsize: image.fullsize
}))
};
case 'external':
return embedView.external
? {
type: 'external',
external: {
href: embedView.external.uri,
title: embedView.external.title,
description: embedView.external.description,
thumb: embedView.external.thumb
}
}
: { type: 'unknown' };
case 'video':
return embedView.playlist
? {
type: 'video',
video: {
playlist: embedView.playlist,
thumb: embedView.thumbnail,
alt: embedView.alt,
aspectRatio: embedView.aspectRatio
}
}
: { type: 'unknown' };
case 'record': {
const record = extractQuotedPost(embedView.record, baseUrl);
return record ? { type: 'record', record } : { type: 'unknown' };
}
case 'recordWithMedia': {
const record = extractQuotedPost(embedView.record?.record, baseUrl);
const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined;
if (record) {
return {
type: 'recordWithMedia',
record,
media: media ?? { type: 'unknown' }
};
}
return media ?? { type: 'unknown' };
}
default:
return { type: 'unknown' };
}
}
export function blueskyPostToPostData(
data: PostView,
baseUrl: string = 'https://bsky.app'
): PostData {
const post = data;
const id = post.uri.split('/').pop();
return {
id,
href: `${baseUrl}/profile/${post.author.handle}/post/${id}`,
author: {
displayName: post.author.displayName || '',
handle: post.author.handle,
avatar: post.author.avatar,
href: `${baseUrl}/profile/${post.author.did}`
},
replyCount: post.replyCount ?? 0,
repostCount: post.repostCount ?? 0,
likeCount: post.likeCount ?? 0,
createdAt: post.record.createdAt as string,
embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined,
htmlContent: blueskyPostToHTML(post, baseUrl),
labels: post.labels ? post.labels.map((label) => label.val) : undefined
};
}
export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') {
if (!post?.record) {
return '';
}
const html = RichText(
{ text: post.record.text as string, facets: post.record.facets as Facet[] },
baseUrl
);
return html.replace(/\n/g, '
');
}
export { default as BlueskyPost } from './BlueskyPost.svelte';