export interface Text { val: string; setInnerHtml: boolean; } interface FacetFeature { $type: string; uri?: string; did?: string; tag?: string; } interface Facet { index: { byteStart: number; byteEnd: number }; features: FacetFeature[]; } interface Segment { text: string; feature?: FacetFeature; } const encoder = new TextEncoder(); const decoder = new TextDecoder(); // Post text is arbitrary user input and the faceted segments below are injected // via innerHTML, so everything interpolated into that markup has to be escaped. const escapeHtml = (value: string): string => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Link facets carry a URI straight from the post record - only ever turn // http(s) into an anchor, so a javascript:/data: URI cannot become a link. The // original string is returned rather than URL's normalised form so the href // stays exactly what the author posted; escapeHtml keeps it inside the // attribute. const safeHref = (uri: string | undefined): string | null => { try { const { protocol } = new URL(String(uri)); return protocol === 'http:' || protocol === 'https:' ? String(uri) : null; } catch { return null; } }; // Splits a post into plain and faceted segments, the way RichText.segments() // from @atproto/api does. The server already sends the facets, so all that is // left is slicing the text - facet indices are UTF-8 byte offsets, so the // encoded form is what gets sliced. const segmentText = (text: string, facets: Facet[]): Segment[] => { const bytes = encoder.encode(text); const sorted = facets .filter(f => f?.index && f.features?.length && f.index.byteStart < f.index.byteEnd) .sort((a, b) => a.index.byteStart - b.index.byteStart); const segments: Segment[] = []; let cursor = 0; for (const facet of sorted) { const { byteStart, byteEnd } = facet.index; if (byteStart < cursor) continue; // overlaps the previous facet - skip it if (byteStart > cursor) { segments.push({ text: decoder.decode(bytes.slice(cursor, byteStart)) }); } segments.push({ text: decoder.decode(bytes.slice(byteStart, byteEnd)), feature: facet.features[0], }); cursor = byteEnd; } if (cursor < bytes.length) { segments.push({ text: decoder.decode(bytes.slice(cursor)) }); } // RichText always yields at least one segment, even for empty text - keep // that so callers never have to special-case an empty post. if (!segments.length) { segments.push({ text }); } return segments; }; interface Reason { $type: string; by: { displayName: string; } } const formatPost: ({ post, reason, isRoot }: { post: any; reason: Reason; isRoot: boolean }) => { createdAt: string; images: any[]; isRepost: boolean; repostBy: string | null | undefined; handle: string; avatar: string; text: Text[]; uri: string; card: any; replyPost: any; username: string; } | null = ({ post, reason, isRoot }) => { if (post.$type === "app.bsky.embed.record#viewNotFound" || post.$type === 'app.bsky.embed.record#viewBlocked') { return null } if (post.$type === "app.bsky.graph.defs#listView") { // Handle list view return { username: post.creator.displayName, handle: post.creator.handle, avatar: post.creator.avatar, text: [{ val: post.description, setInnerHtml: false }], createdAt: post.indexedAt, uri: post.uri, images: [], card: null, replyPost: null, isRepost: false, repostBy: null, }; } if (post.$type === "app.bsky.feed.defs#generatorView") { // Handle a quoted feed generator. Unlike a quoted post it carries no // `record`, so it has to be mapped here or the post below would throw. return { username: post.creator.displayName, handle: post.creator.handle, avatar: post.creator.avatar, text: [{ val: post.description, setInnerHtml: false }], createdAt: post.indexedAt, uri: post.uri, images: [], card: null, replyPost: null, isRepost: false, repostBy: null, }; } // Any other embedded record without a `record` (labelers, detached quotes, // future types) is not renderable as a post - skip it instead of throwing. if (!post.record) { return null } // Existing post handling code const facets = post.record.facets || []; const rawText = post.record.text; const text: Text[] = []; for (const segment of segmentText(rawText, facets)) { const feature = segment.feature; const href = feature?.$type === 'app.bsky.richtext.facet#link' ? safeHref(feature.uri) : null; if (href) { text.push({ val: `${escapeHtml(segment.text)}`, setInnerHtml: true, }) } else if (feature?.$type === 'app.bsky.richtext.facet#mention') { text.push({ val: `${escapeHtml(segment.text)}`, setInnerHtml: true, }) } else if (feature?.$type === 'app.bsky.richtext.facet#tag') { text.push({ val: `${escapeHtml(segment.text)}`, setInnerHtml: true, }) } else { text.push({ val: segment.text, setInnerHtml: false, }) } } const replyPost = post.embed?.$type === 'app.bsky.embed.record#view' ? post.embed.record : post.embed?.record?.record?.$type === 'app.bsky.embed.record#viewRecord' && post.embed.record.record const formattedReply = replyPost && { ...replyPost, record: replyPost.value || replyPost.record, embed: (replyPost?.embeds || [])[0] } const author = post.author || post.creator return { username: author.displayName, handle: author.handle, avatar: author.avatar, // todo fallback text, createdAt: post.record.createdAt, uri: post.uri, images: [ ...post.embed?.images || [], ...post.embed?.media?.images || [], ...[(post.embed?.media?.external)].filter(Boolean).map((image: any) => ({ ...image, alt: image.title, thumb: image.uri })) ], video: post.embed?.$type === 'app.bsky.embed.video#view' && post.embed, card: post.embed?.$type === 'app.bsky.embed.external#view' && post.embed?.external, replyPost: isRoot && formattedReply && formatPost({ post: formattedReply, reason: { $type: '', by: { displayName: '' } }, isRoot: false }), isRepost: reason?.$type === 'app.bsky.feed.defs#reasonRepost', repostBy: reason?.by?.displayName } }; export const formatData = (data: any) => (data.feed || []).map((post: { post: any; reason: any; isRoot: any; }) => formatPost({ ...post, isRoot: true })) export const getContentAfterLastSlash = (str: string): string => { const lastIndex: number = str.lastIndexOf("/"); if (lastIndex !== -1) { return str.substring(lastIndex + 1); } else { return str; } } export const timeDifference = (previous: Date): string => { const current: Date = new Date(); const msPerMinute: number = 60 * 1000; const msPerHour: number = msPerMinute * 60; const msPerDay: number = msPerHour * 24; const msPerMonth: number = msPerDay * 30; const msPerYear: number = msPerDay * 365; const elapsed: number = current.getTime() - previous.getTime(); if (elapsed < msPerMinute) { return Math.floor(elapsed / 1000) + 's'; } else if (elapsed < msPerHour) { return Math.floor(elapsed / msPerMinute) + 'm'; } else if (elapsed < msPerDay) { return Math.floor(elapsed / msPerHour) + 'h'; } else if (elapsed < msPerMonth) { return Math.floor(elapsed / msPerDay) + 'd'; } else if (elapsed < msPerYear) { return Math.floor(elapsed / msPerMonth) + ' mo'; } else { return Math.floor(elapsed / msPerYear) + ' yr'; } } export const fetchVideo = async (video: any, videoRef: any, disableAutoplay: boolean = false) => { if (!('IntersectionObserver' in window)) { console.error('IntersectionObserver not supported'); return; } if (!videoRef) { console.error('Video element not found'); return; } const observerOptions = { root: null, // Viewport is the root by default threshold: 0.5 // 50% of the video must be visible to trigger playback }; const onIntersect = async (entries, observer) => { for (const entry of entries) { if (!entry.isIntersecting) continue; // Optionally, unobserve once video starts playing if you don't need to watch for it going out of view observer.unobserve(videoRef); // Video is in view - load and play. hls.js is only pulled in at this // point, so feeds without videos never pay for it. const { default: Hls } = await import('hls.js/light'); if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(video.playlist); // Load the HLS manifest hls.attachMedia(videoRef); // Attach to video element hls.on(Hls.Events.MANIFEST_PARSED, () => { if (!disableAutoplay) { videoRef.play(); } }); } else if (videoRef.canPlayType('application/vnd.apple.mpegurl')) { // Fallback for native HLS support in Safari videoRef.src = video.playlist; videoRef.addEventListener('loadedmetadata', () => { if (!disableAutoplay) { videoRef.play(); } }); } } }; // Create the observer const observer = new IntersectionObserver(onIntersect, observerOptions); observer.observe(videoRef); // Observe the video element };