BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import * as logger from "@tauri-apps/plugin-log";
2import {
3 FEED_COLLECTION,
4 LABELER_COLLECTION,
5 LIST_COLLECTION,
6 POST_COLLECTION,
7 STARTER_PACK_COLLECTION,
8} from "./constants/collections";
9import {
10 isFeedViewPost,
11 isProfileViewBasic,
12 isQuoteEmbed,
13 isReplyByUnfollowed,
14 isReplyItem,
15 isRepostReason,
16 isThreadNode,
17 isThreadViewPost,
18} from "./feeds/type-guards";
19import { asArray, asRecord } from "./type-guards";
20import type {
21 EmbedView,
22 FeedGeneratorsResponse,
23 FeedResponse,
24 FeedViewPost,
25 FeedViewPrefItem,
26 Maybe,
27 PostRecord,
28 PostView,
29 ProfileViewBasic,
30 RichTextFacet,
31 SavedFeedItem,
32 StrongRefInput,
33 ThreadNode,
34 ThreadResponse,
35} from "./types";
36import { hashString, stringifyUnknown } from "./utils/text";
37
38export const TIMELINE_ROUTE = "/timeline";
39
40const THREAD_QUERY_PARAM = "thread";
41
42function asPostRecord(value: unknown): PostRecord {
43 return (asRecord(value) ?? {}) as PostRecord;
44}
45
46export function parseFeedResponse(value: unknown): FeedResponse {
47 const record = asRecord(value);
48 const feed = asArray(record?.feed);
49
50 if (!record || !feed || !feed.every((item) => isFeedViewPost(item))) {
51 throw new Error("feed response payload is invalid");
52 }
53
54 if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") {
55 throw new Error("feed response cursor is invalid");
56 }
57
58 return { cursor: typeof record.cursor === "string" ? record.cursor : null, feed };
59}
60
61export function parseThreadResponse(value: unknown): ThreadResponse {
62 const record = asRecord(value);
63 if (!record || !isThreadNode(record.thread)) {
64 throw new Error("thread response payload is invalid");
65 }
66
67 return { thread: record.thread };
68}
69
70export function parseFeedGeneratorsResponse(value: unknown): FeedGeneratorsResponse {
71 const record = asRecord(value);
72 const feeds = asArray(record?.feeds);
73
74 if (!record || !feeds) {
75 throw new Error("feed generators payload is invalid");
76 }
77
78 return { feeds: feeds as FeedGeneratorsResponse["feeds"] };
79}
80
81export function getPostText(post: PostView) {
82 const text = post.record.text;
83 return typeof text === "string" ? text : "";
84}
85
86export function getPostFacets(post: PostView) {
87 const facets = asPostRecord(post.record).facets;
88 return Array.isArray(facets) ? facets : [];
89}
90
91export function getPostCreatedAt(post: PostView) {
92 const createdAt = post.record.createdAt;
93 return typeof createdAt === "string" ? createdAt : post.indexedAt;
94}
95
96export function getDisplayName(author: ProfileViewBasic) {
97 return author.displayName?.trim() || author.handle;
98}
99
100export function getAvatarLabel(author: ProfileViewBasic) {
101 return getDisplayName(author).slice(0, 1).toUpperCase() || "?";
102}
103
104export function getFeedName(item: { type: string; value: string }, hydratedName?: string | null) {
105 if (item.type === "timeline") {
106 return item.value === "following" ? "Following" : "Timeline";
107 }
108
109 if (hydratedName) {
110 return hydratedName;
111 }
112
113 const segment = item.value.split("/").at(-1)?.trim();
114 if (segment) {
115 return segment.replaceAll("-", " ");
116 }
117
118 return item.type === "list" ? "List" : "Custom feed";
119}
120
121export function getFeedCommand(feed: SavedFeedItem) {
122 if (feed.type === "timeline") {
123 return { args: (cursor: string | null, limit: number) => ({ cursor, limit }), name: "get_timeline" as const };
124 }
125
126 if (feed.type === "list") {
127 return {
128 args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }),
129 name: "get_list_feed" as const,
130 };
131 }
132
133 return {
134 args: (cursor: string | null, limit: number) => ({ cursor, limit, uri: feed.value }),
135 name: "get_feed" as const,
136 };
137}
138
139export function hasKnownThreadContext(post: PostView, item?: FeedViewPost) {
140 if (item && isReplyItem(item)) {
141 return true;
142 }
143
144 if (asRecord(asRecord(post.record)?.reply)) {
145 return true;
146 }
147
148 return typeof post.replyCount === "number" && post.replyCount > 0;
149}
150
151export function getReplyRootPost(item: FeedViewPost) {
152 if (item.reply?.root.$type === "app.bsky.feed.defs#postView") {
153 return item.reply.root;
154 }
155
156 return item.post;
157}
158
159export function toStrongRef(post: PostView) {
160 return { cid: post.cid, uri: post.uri } satisfies StrongRefInput;
161}
162
163export function extractHashtags(posts: PostView[]) {
164 const tags = new Set<string>();
165 for (const post of posts) {
166 for (const match of getPostText(post).matchAll(/#[\p{L}\p{N}_-]+/gu)) {
167 tags.add(match[0]);
168 }
169 }
170
171 return [...tags].toSorted((left, right) => left.localeCompare(right));
172}
173
174export function extractHandles(posts: PostView[], activeHandle: string | null) {
175 const handles = new Set<string>();
176 for (const post of posts) {
177 const handle = normalizeHandle(post.author.handle);
178 if (handle) {
179 handles.add(`@${handle}`);
180 }
181 }
182
183 const normalizedActiveHandle = normalizeHandle(activeHandle);
184 if (normalizedActiveHandle) {
185 handles.add(`@${normalizedActiveHandle}`);
186 }
187
188 return [...handles].toSorted((left, right) => left.localeCompare(right));
189}
190
191export function applyFeedPreferences(items: FeedViewPost[], pref: FeedViewPrefItem) {
192 return items.filter((item) => {
193 if (pref.hideReposts && isRepostReason(item)) {
194 return false;
195 }
196
197 if (pref.hideReplies && isReplyItem(item)) {
198 return false;
199 }
200
201 if (pref.hideRepliesByUnfollowed && isReplyByUnfollowed(item)) {
202 return false;
203 }
204
205 if (pref.hideQuotePosts && isQuoteEmbed(item.post.embed)) {
206 return false;
207 }
208
209 if (
210 pref.hideRepliesByLikeCount !== null
211 && isReplyItem(item)
212 && (item.post.likeCount ?? 0) < pref.hideRepliesByLikeCount
213 ) {
214 return false;
215 }
216
217 return true;
218 });
219}
220
221type QuotedRecordKind =
222 | "blocked"
223 | "detached"
224 | "feed"
225 | "labeler"
226 | "list"
227 | "not-found"
228 | "post"
229 | "starter-pack"
230 | "unknown";
231
232type QuotedRecordVariant =
233 | "generatorView"
234 | "labelerView"
235 | "listView"
236 | "open-union"
237 | "starterPackViewBasic"
238 | "viewBlocked"
239 | "viewDetached"
240 | "viewNotFound"
241 | "viewRecord";
242
243type EmbedCanonicalKind = "external" | "images" | "record" | "recordWithMedia" | "video";
244
245export type NormalizedEmbedSource =
246 | "quoted"
247 | "recordWithMedia.media"
248 | "top"
249 | "value.embed"
250 | "value.embeds"
251 | "viewRecord.embeds";
252
253type NormalizationMeta = {
254 cycle: boolean;
255 depth: number;
256 depthLimited: boolean;
257 explicitType: string | null;
258 inferred: boolean;
259 source: NormalizedEmbedSource;
260};
261
262export type UnknownEmbedEntry = {
263 explicitType: string | null;
264 fingerprint: string;
265 inferred: boolean;
266 raw: unknown;
267 source: NormalizedEmbedSource;
268};
269
270export type QuotedRecordPresentation = {
271 author: ProfileViewBasic | null;
272 emptyText: string;
273 facets: RichTextFacet[] | null;
274 href: string | null;
275 kind: QuotedRecordKind;
276 normalizedEmbeds: NormalizedEmbed[];
277 text: string | null;
278 title: string;
279 unknownEmbeds: UnknownEmbedEntry[];
280 uri: string | null;
281};
282
283export type NormalizedQuotedRecord = QuotedRecordPresentation & {
284 cycle: boolean;
285 depth: number;
286 depthLimited: boolean;
287 variant: QuotedRecordVariant;
288};
289
290export type NormalizedEmbed =
291 | { embed: Extract<EmbedView, { $type: "app.bsky.embed.external#view" }>; kind: "external"; meta: NormalizationMeta }
292 | { embed: Extract<EmbedView, { $type: "app.bsky.embed.images#view" }>; kind: "images"; meta: NormalizationMeta }
293 | { kind: "record"; meta: NormalizationMeta; quoted: NormalizedQuotedRecord }
294 | { kind: "recordWithMedia"; media: NormalizedEmbed | null; meta: NormalizationMeta; quoted: NormalizedQuotedRecord }
295 | { kind: "recognized-unrenderable"; message: string; meta: NormalizationMeta; recognizedType: string }
296 | { kind: "unknown"; meta: NormalizationMeta; unknown: UnknownEmbedEntry }
297 | { embed: Extract<EmbedView, { $type: "app.bsky.embed.video#view" }>; kind: "video"; meta: NormalizationMeta };
298
299type NormalizeEmbedOptions = {
300 depth?: number;
301 maxDepth?: number;
302 source?: NormalizedEmbedSource;
303 trail?: WeakSet<object>;
304};
305
306type NormalizeEmbedContext = { depth: number; maxDepth: number; source: NormalizedEmbedSource; trail: WeakSet<object> };
307type QuotedRecordClassification = { kind: QuotedRecordKind; variant: QuotedRecordVariant };
308
309const DEFAULT_NORMALIZE_EMBED_MAX_DEPTH = 6;
310const UNKNOWN_EMBED_WARN_INTERVAL = 25;
311const unknownEmbedTelemetry = new Map<string, number>();
312
313const VIEW_TYPE_TO_KIND: Readonly<Record<string, EmbedCanonicalKind>> = {
314 "app.bsky.embed.external#view": "external",
315 "app.bsky.embed.images#view": "images",
316 "app.bsky.embed.record#view": "record",
317 "app.bsky.embed.recordWithMedia#view": "recordWithMedia",
318 "app.bsky.embed.video#view": "video",
319};
320
321const MAIN_TYPE_TO_KIND: Readonly<Record<string, EmbedCanonicalKind>> = {
322 "app.bsky.embed.external": "external",
323 "app.bsky.embed.images": "images",
324 "app.bsky.embed.record": "record",
325 "app.bsky.embed.recordWithMedia": "recordWithMedia",
326 "app.bsky.embed.video": "video",
327};
328
329const QUOTED_RECORD_TYPE_CLASSIFICATION: Readonly<Record<string, QuotedRecordClassification>> = {
330 "app.bsky.embed.record#viewBlocked": { kind: "blocked", variant: "viewBlocked" },
331 "app.bsky.embed.record#viewDetached": { kind: "detached", variant: "viewDetached" },
332 "app.bsky.embed.record#viewNotFound": { kind: "not-found", variant: "viewNotFound" },
333 "app.bsky.embed.record#viewRecord": { kind: "post", variant: "viewRecord" },
334 "app.bsky.feed.defs#generatorView": { kind: "feed", variant: "generatorView" },
335 "app.bsky.graph.defs#listView": { kind: "list", variant: "listView" },
336 "app.bsky.graph.defs#starterPackViewBasic": { kind: "starter-pack", variant: "starterPackViewBasic" },
337 "app.bsky.labeler.defs#labelerView": { kind: "labeler", variant: "labelerView" },
338};
339
340export function resetUnknownEmbedTelemetryForTests() {
341 unknownEmbedTelemetry.clear();
342}
343
344export function getUnknownEmbedTelemetryForTests() {
345 return new Map(unknownEmbedTelemetry);
346}
347
348function debugEmbedKey(unknown: UnknownEmbedEntry) {
349 return `${unknown.source}|${unknown.inferred ? "inferred" : "explicit"}|${unknown.fingerprint}`;
350}
351
352function trackUnknownEmbedTelemetry(unknown: UnknownEmbedEntry) {
353 const key = debugEmbedKey(unknown);
354 const count = (unknownEmbedTelemetry.get(key) ?? 0) + 1;
355 unknownEmbedTelemetry.set(key, count);
356 if (count !== 1 && count % UNKNOWN_EMBED_WARN_INTERVAL !== 0) {
357 return;
358 }
359
360 logger.warn("unknown embed shape encountered", {
361 keyValues: {
362 count: String(count),
363 explicitType: unknown.explicitType ?? "none",
364 fingerprint: unknown.fingerprint,
365 inferred: String(unknown.inferred),
366 payloadJson: stringifyUnknown(unknown.raw),
367 source: unknown.source,
368 },
369 });
370}
371
372function assertNever(value: never): never {
373 throw new Error(`Unhandled value: ${String(value)}`);
374}
375
376function shapeSignature(value: unknown, depth = 0, seen = new WeakSet<object>()): string {
377 if (depth > 3) {
378 return "depth-limit";
379 }
380
381 if (value === null) {
382 return "null";
383 }
384
385 if (Array.isArray(value)) {
386 const preview = value.slice(0, 3).map((item) => shapeSignature(item, depth + 1, seen));
387 return `array(${value.length})[${preview.join(",")}]`;
388 }
389
390 const record = asRecord(value);
391 if (record) {
392 if (seen.has(record)) {
393 return "cycle";
394 }
395 seen.add(record);
396 const keys = Object.keys(record).toSorted().slice(0, 12);
397 const parts = keys.map((key) => `${key}:${shapeSignature(record[key], depth + 1, seen)}`);
398 seen.delete(record);
399 return `object{${parts.join("|")}}`;
400 }
401
402 return typeof value;
403}
404
405function buildEmbedFingerprint(value: unknown, explicitType: string | null, inferred: boolean) {
406 const shapeHash = hashString(shapeSignature(value));
407 const typePart = explicitType ?? (inferred ? "inferred-shape" : "untyped");
408 return `${typePart}:${shapeHash}`;
409}
410
411function asAspectRatio(value: unknown) {
412 const ratio = asRecord(value);
413 if (!ratio || typeof ratio.width !== "number" || typeof ratio.height !== "number") {
414 return;
415 }
416
417 return { height: ratio.height, width: ratio.width };
418}
419
420function buildMeta(
421 context: NormalizeEmbedContext,
422 options: Partial<Pick<NormalizationMeta, "cycle" | "depthLimited" | "explicitType" | "inferred">> = {},
423): NormalizationMeta {
424 return {
425 cycle: options.cycle ?? false,
426 depth: context.depth,
427 depthLimited: options.depthLimited ?? false,
428 explicitType: options.explicitType ?? null,
429 inferred: options.inferred ?? false,
430 source: context.source,
431 };
432}
433
434function childContext(parent: NormalizeEmbedContext, source: NormalizedEmbedSource): NormalizeEmbedContext {
435 return { depth: parent.depth + 1, maxDepth: parent.maxDepth, source, trail: parent.trail };
436}
437
438function canonicalEmbedKindFromType(type: string | null) {
439 if (!type) {
440 return null;
441 }
442 if (Object.prototype.hasOwnProperty.call(VIEW_TYPE_TO_KIND, type)) {
443 return VIEW_TYPE_TO_KIND[type];
444 }
445 if (Object.prototype.hasOwnProperty.call(MAIN_TYPE_TO_KIND, type)) {
446 return MAIN_TYPE_TO_KIND[type];
447 }
448
449 return null;
450}
451
452function inferCanonicalEmbedKind(record: Record<string, unknown>): EmbedCanonicalKind | null {
453 if (asRecord(record.record) && asRecord(record.media)) {
454 return "recordWithMedia";
455 }
456 if (asRecord(record.record)) {
457 return "record";
458 }
459 if (Array.isArray(record.images)) {
460 return "images";
461 }
462 if (asRecord(record.external)) {
463 return "external";
464 }
465 if (
466 Object.prototype.hasOwnProperty.call(record, "playlist")
467 || Object.prototype.hasOwnProperty.call(record, "thumbnail")
468 || Object.prototype.hasOwnProperty.call(record, "video")
469 ) {
470 return "video";
471 }
472
473 return null;
474}
475
476function unknownNormalizedEmbed(
477 value: unknown,
478 context: NormalizeEmbedContext,
479 explicitType: string | null,
480 inferred: boolean,
481): Extract<NormalizedEmbed, { kind: "unknown" }> {
482 const unknown: UnknownEmbedEntry = {
483 explicitType,
484 fingerprint: buildEmbedFingerprint(value, explicitType, inferred),
485 inferred,
486 raw: value,
487 source: context.source,
488 };
489 trackUnknownEmbedTelemetry(unknown);
490 return { kind: "unknown", meta: buildMeta(context, { explicitType, inferred }), unknown };
491}
492
493function recognizedUnrenderableEmbed(
494 context: NormalizeEmbedContext,
495 recognizedType: string,
496 message: string,
497 raw: unknown,
498 options: Partial<Pick<NormalizationMeta, "cycle" | "depthLimited" | "explicitType" | "inferred">> = {},
499): Extract<NormalizedEmbed, { kind: "recognized-unrenderable" }> {
500 const rawRecord = asRecord(raw);
501 const topLevelKeys = rawRecord ? Object.keys(rawRecord).toSorted().slice(0, 24).join(",") : "none";
502
503 logger.warn("recognized embed shape could not be rendered", {
504 keyValues: {
505 embedShape: shapeSignature(raw),
506 explicitType: options.explicitType ?? "none",
507 inferred: String(options.inferred ?? false),
508 message,
509 payloadJson: stringifyUnknown(raw),
510 recognizedType,
511 source: context.source,
512 topLevelKeys,
513 },
514 });
515 return { kind: "recognized-unrenderable", message, meta: buildMeta(context, options), recognizedType };
516}
517
518function normalizeImagesEmbedView(record: Record<string, unknown>) {
519 const images = asArray(record.images);
520 if (!images) {
521 return null;
522 }
523
524 const normalizedImages = images.map((item) => asRecord(item)).filter((item): item is Record<string, unknown> =>
525 !!item
526 ).map((item) => {
527 const fullsize = typeof item.fullsize === "string" ? item.fullsize : undefined;
528 const thumb = typeof item.thumb === "string" ? item.thumb : undefined;
529 if (!fullsize && !thumb) {
530 return null;
531 }
532
533 return {
534 alt: typeof item.alt === "string" ? item.alt : undefined,
535 aspectRatio: asAspectRatio(item.aspectRatio),
536 fullsize,
537 thumb,
538 };
539 }).filter((item): item is NonNullable<typeof item> => !!item);
540
541 if (normalizedImages.length === 0) {
542 return null;
543 }
544
545 return { $type: "app.bsky.embed.images#view", images: normalizedImages } as const;
546}
547
548function blobCidFromRecord(record: Record<string, unknown> | null) {
549 if (!record) {
550 return null;
551 }
552
553 if (typeof record.$link === "string" && record.$link.trim().length > 0) {
554 return record.$link.trim();
555 }
556
557 if (typeof record.ref === "string" && record.ref.trim().length > 0) {
558 return record.ref.trim();
559 }
560
561 const ref = asRecord(record.ref);
562 if (ref && typeof ref.$link === "string" && ref.$link.trim().length > 0) {
563 return ref.$link.trim();
564 }
565
566 return null;
567}
568
569function imageFormatFromMimeType(mimeType: unknown) {
570 if (typeof mimeType !== "string") {
571 return "jpeg";
572 }
573
574 const normalized = mimeType.trim().toLowerCase();
575 if (normalized === "image/png") {
576 return "png";
577 }
578 if (normalized === "image/webp") {
579 return "webp";
580 }
581 if (normalized === "image/gif") {
582 return "gif";
583 }
584
585 return "jpeg";
586}
587
588function withBlobBackedImageUrls(value: unknown, authorDid: string | null) {
589 if (!authorDid) {
590 return value;
591 }
592
593 const record = asRecord(value);
594 if (!record) {
595 return value;
596 }
597
598 const explicitType = typeof record.$type === "string" ? record.$type : null;
599 if (explicitType && explicitType !== "app.bsky.embed.images" && explicitType !== "app.bsky.embed.images#view") {
600 return value;
601 }
602
603 const images = asArray(record.images);
604 if (!images || images.length === 0) {
605 return value;
606 }
607
608 let changed = false;
609 const resolvedImages = images.map((entry) => {
610 const imageRecord = asRecord(entry);
611 if (!imageRecord) {
612 return entry;
613 }
614
615 const hasViewUrls = typeof imageRecord.fullsize === "string" || typeof imageRecord.thumb === "string";
616 if (hasViewUrls) {
617 return imageRecord;
618 }
619
620 const blobRecord = asRecord(imageRecord.image);
621 const cid = blobCidFromRecord(blobRecord);
622 if (!cid) {
623 return imageRecord;
624 }
625
626 changed = true;
627 const format = imageFormatFromMimeType(blobRecord?.mimeType);
628 const encodedDid = encodeURIComponent(authorDid);
629 const encodedCid = encodeURIComponent(cid);
630
631 return {
632 ...imageRecord,
633 fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${encodedDid}/${encodedCid}@${format}`,
634 thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${encodedDid}/${encodedCid}@${format}`,
635 };
636 });
637
638 if (!changed) {
639 return value;
640 }
641
642 return { ...record, images: resolvedImages };
643}
644
645function normalizeExternalEmbedView(record: Record<string, unknown>) {
646 const external = asRecord(record.external);
647 if (!external) {
648 return null;
649 }
650
651 const normalized = {
652 description: typeof external.description === "string" ? external.description : undefined,
653 thumb: typeof external.thumb === "string" ? external.thumb : undefined,
654 title: typeof external.title === "string" ? external.title : undefined,
655 uri: typeof external.uri === "string" ? external.uri : undefined,
656 };
657
658 if (!normalized.description && !normalized.thumb && !normalized.title && !normalized.uri) {
659 return null;
660 }
661
662 return { $type: "app.bsky.embed.external#view", external: normalized } as const;
663}
664
665function normalizeVideoEmbedView(record: Record<string, unknown>) {
666 const normalized = {
667 alt: typeof record.alt === "string" ? record.alt : undefined,
668 aspectRatio: asAspectRatio(record.aspectRatio),
669 playlist: typeof record.playlist === "string" ? record.playlist : undefined,
670 thumbnail: typeof record.thumbnail === "string" ? record.thumbnail : undefined,
671 };
672
673 if (!normalized.alt && !normalized.aspectRatio && !normalized.playlist && !normalized.thumbnail) {
674 return null;
675 }
676
677 return { $type: "app.bsky.embed.video#view", ...normalized } as const;
678}
679
680function getProfileFromRecord(record: Record<string, unknown>, keys: string[]) {
681 for (const key of keys) {
682 const candidate = asRecord(record[key]);
683 if (candidate && isProfileViewBasic(candidate)) {
684 return candidate;
685 }
686 }
687
688 return null;
689}
690
691function atUriParts(value: Maybe<string>) {
692 if (typeof value !== "string") {
693 return null;
694 }
695
696 const trimmed = value.trim();
697 if (!trimmed.startsWith("at://")) {
698 return null;
699 }
700
701 const segments = trimmed.slice(5).split("/").map((segment) => segment.trim()).filter((segment) => segment.length > 0);
702 if (segments.length === 0) {
703 return null;
704 }
705
706 return {
707 collection: segments.length > 1 ? segments[1] : null,
708 did: segments[0],
709 rkey: segments.length > 2 ? segments[2] : null,
710 uri: trimmed,
711 };
712}
713
714function classifyQuotedRecord(record: Record<string, unknown>): QuotedRecordClassification {
715 const type = typeof record.$type === "string" ? record.$type : null;
716 if (type && Object.prototype.hasOwnProperty.call(QUOTED_RECORD_TYPE_CLASSIFICATION, type)) {
717 return QUOTED_RECORD_TYPE_CLASSIFICATION[type];
718 }
719 if (record.blocked === true) {
720 return { kind: "blocked", variant: "viewBlocked" };
721 }
722 if (record.detached === true) {
723 return { kind: "detached", variant: "viewDetached" };
724 }
725 if (record.notFound === true) {
726 return { kind: "not-found", variant: "viewNotFound" };
727 }
728
729 const uriCollection = atUriParts(typeof record.uri === "string" ? record.uri : null)?.collection;
730 if (uriCollection === POST_COLLECTION) {
731 return { kind: "post", variant: "open-union" };
732 }
733 if (uriCollection === FEED_COLLECTION) {
734 return { kind: "feed", variant: "open-union" };
735 }
736 if (uriCollection === LIST_COLLECTION) {
737 return { kind: "list", variant: "open-union" };
738 }
739 if (uriCollection === STARTER_PACK_COLLECTION) {
740 return { kind: "starter-pack", variant: "open-union" };
741 }
742 if (uriCollection === LABELER_COLLECTION) {
743 return { kind: "labeler", variant: "open-union" };
744 }
745
746 const valueRecord = asRecord(record.value);
747 if (valueRecord?.$type === POST_COLLECTION || typeof valueRecord?.text === "string") {
748 return { kind: "post", variant: "open-union" };
749 }
750
751 return { kind: "unknown", variant: "open-union" };
752}
753
754function quotedRecordText(kind: QuotedRecordKind, record: Record<string, unknown>) {
755 if (kind === "post") {
756 const valueText = asRecord(record.value)?.text;
757 if (typeof valueText === "string" && valueText.trim().length > 0) {
758 return valueText;
759 }
760
761 const postRecordText = asRecord(record.record)?.text;
762 const text = typeof postRecordText === "string" ? postRecordText : record.text;
763 return typeof text === "string" && text.trim().length > 0 ? text : null;
764 }
765 if (kind === "feed") {
766 const displayName = record.displayName;
767 if (typeof displayName === "string" && displayName.trim().length > 0) {
768 return displayName;
769 }
770
771 const description = record.description;
772 return typeof description === "string" && description.trim().length > 0 ? description : null;
773 }
774 if (kind === "list") {
775 const name = record.name;
776 if (typeof name === "string" && name.trim().length > 0) {
777 return name;
778 }
779
780 const description = record.description;
781 return typeof description === "string" && description.trim().length > 0 ? description : null;
782 }
783 if (kind === "labeler") {
784 return "Moderation service";
785 }
786 if (kind === "starter-pack") {
787 const name = asRecord(record.record)?.name;
788 if (typeof name === "string" && name.trim().length > 0) {
789 return name;
790 }
791 return "Starter pack";
792 }
793 if (kind === "blocked") {
794 return "This record is blocked.";
795 }
796 if (kind === "not-found") {
797 return "This record was not found.";
798 }
799 if (kind === "detached") {
800 return "This record has been detached.";
801 }
802
803 return "Unsupported embedded record.";
804}
805
806function quotedRecordTitles(kind: QuotedRecordKind) {
807 if (kind === "post") {
808 return { emptyText: "Quoted post", title: "Quoted post" };
809 }
810 if (kind === "feed") {
811 return { emptyText: "Feed", title: "Embedded feed" };
812 }
813 if (kind === "list") {
814 return { emptyText: "List", title: "Embedded list" };
815 }
816 if (kind === "labeler") {
817 return { emptyText: "Labeler", title: "Embedded labeler" };
818 }
819 if (kind === "starter-pack") {
820 return { emptyText: "Starter pack", title: "Embedded starter pack" };
821 }
822 if (kind === "blocked") {
823 return { emptyText: "This record is blocked.", title: "Embedded record" };
824 }
825 if (kind === "not-found") {
826 return { emptyText: "This record was not found.", title: "Embedded record" };
827 }
828 if (kind === "detached") {
829 return { emptyText: "This record has been detached.", title: "Embedded record" };
830 }
831
832 return { emptyText: "Unsupported embedded record.", title: "Embedded record" };
833}
834
835function quotedRecordFacets(kind: QuotedRecordKind, record: Record<string, unknown>) {
836 if (kind !== "post") {
837 return null;
838 }
839
840 const facets = asRecord(record.value)?.facets ?? asRecord(record.record)?.facets;
841 return Array.isArray(facets) ? (facets as RichTextFacet[]) : null;
842}
843
844type QuotedEmbedExtraction = { source: "value.embed" | "value.embeds" | "viewRecord.embeds"; values: unknown[] };
845
846function quotedEmbedExtraction(record: Record<string, unknown>): QuotedEmbedExtraction | null {
847 // Prefer hydrated view fields first, then fall back to raw record payload fields.
848 if (Object.prototype.hasOwnProperty.call(record, "embeds")) {
849 const direct = asArray(record.embeds);
850 return { source: "viewRecord.embeds", values: direct ?? (record.embeds === undefined ? [] : [record.embeds]) };
851 }
852
853 const postRecord = asRecord(record.record);
854 if (postRecord && Object.prototype.hasOwnProperty.call(postRecord, "embed")) {
855 if (postRecord.embed === null || postRecord.embed === undefined) {
856 return { source: "value.embed", values: [] };
857 }
858 return { source: "value.embed", values: [postRecord.embed] };
859 }
860
861 const value = asRecord(record.value);
862 if (value) {
863 if (Object.prototype.hasOwnProperty.call(value, "embed")) {
864 if (value.embed === null || value.embed === undefined) {
865 return { source: "value.embed", values: [] };
866 }
867 return { source: "value.embed", values: [value.embed] };
868 }
869
870 if (Object.prototype.hasOwnProperty.call(value, "embeds")) {
871 const embeds = asArray(value.embeds);
872 return { source: "value.embeds", values: embeds ?? (value.embeds === undefined ? [] : [value.embeds]) };
873 }
874 }
875
876 return null;
877}
878
879function collectUnknownEmbeds(embed: NormalizedEmbed, unknowns: UnknownEmbedEntry[]) {
880 if (embed.kind === "unknown") {
881 unknowns.push(embed.unknown);
882 return;
883 }
884
885 if (embed.kind === "record") {
886 unknowns.push(...embed.quoted.unknownEmbeds);
887 return;
888 }
889
890 if (embed.kind === "recordWithMedia") {
891 if (embed.media) {
892 collectUnknownEmbeds(embed.media, unknowns);
893 }
894 unknowns.push(...embed.quoted.unknownEmbeds);
895 }
896}
897
898function recordPayloadFromRecordWithMedia(record: Record<string, unknown>) {
899 const outer = asRecord(record.record);
900 if (!outer) {
901 return null;
902 }
903
904 const nested = asRecord(outer.record);
905 if (nested) {
906 return nested;
907 }
908
909 return outer;
910}
911
912function toPresentation(record: NormalizedQuotedRecord): QuotedRecordPresentation {
913 return {
914 author: record.author,
915 emptyText: record.emptyText,
916 facets: record.facets,
917 href: record.href,
918 kind: record.kind,
919 normalizedEmbeds: record.normalizedEmbeds,
920 text: record.text,
921 title: record.title,
922 unknownEmbeds: record.unknownEmbeds,
923 uri: record.uri,
924 };
925}
926
927function fallbackQuotedPresentation(kind: QuotedRecordKind, context: NormalizeEmbedContext): NormalizedQuotedRecord {
928 const { emptyText, title } = quotedRecordTitles(kind);
929 return {
930 author: null,
931 cycle: false,
932 depth: context.depth,
933 depthLimited: context.depth > context.maxDepth,
934 emptyText,
935 facets: null,
936 href: null,
937 kind,
938 normalizedEmbeds: [],
939 text: quotedRecordText(kind, {}),
940 title,
941 unknownEmbeds: [],
942 uri: null,
943 variant: "open-union",
944 };
945}
946
947function normalizeQuotedEmbeds(record: Record<string, unknown>, context: NormalizeEmbedContext) {
948 const extraction = quotedEmbedExtraction(record);
949 if (!extraction) {
950 return { normalizedEmbeds: [] as NormalizedEmbed[], unknownEmbeds: [] as UnknownEmbedEntry[] };
951 }
952
953 const authorDid = (() => {
954 const author = asRecord(record.author);
955 if (author && typeof author.did === "string" && author.did.trim().length > 0) {
956 return author.did.trim();
957 }
958
959 const parts = atUriParts(typeof record.uri === "string" ? record.uri : null);
960 return parts?.did ?? null;
961 })();
962
963 const normalizedEmbeds = extraction.values.map((value) =>
964 normalizeEmbed(withBlobBackedImageUrls(value, authorDid), {
965 depth: context.depth + 1,
966 maxDepth: context.maxDepth,
967 source: extraction.source,
968 trail: context.trail,
969 })
970 );
971 const unknownEmbeds: UnknownEmbedEntry[] = [];
972 for (const normalized of normalizedEmbeds) {
973 collectUnknownEmbeds(normalized, unknownEmbeds);
974 }
975
976 return { normalizedEmbeds, unknownEmbeds };
977}
978
979function normalizeQuotedRecord(recordValue: unknown, context: NormalizeEmbedContext): NormalizedQuotedRecord {
980 const record = asRecord(recordValue);
981 if (!record) {
982 return fallbackQuotedPresentation("unknown", context);
983 }
984
985 if (context.depth > context.maxDepth) {
986 return fallbackQuotedPresentation("unknown", context);
987 }
988
989 if (context.trail.has(record)) {
990 const fallback = fallbackQuotedPresentation("unknown", context);
991 return { ...fallback, cycle: true };
992 }
993
994 context.trail.add(record);
995 try {
996 const classification = classifyQuotedRecord(record);
997 const { kind, variant } = classification;
998 const author = getProfileFromRecord(record, ["author", "creator"]);
999 const uri = typeof record.uri === "string" && record.uri.trim().length > 0 ? record.uri : null;
1000 const { emptyText, title } = quotedRecordTitles(kind);
1001 const normalized = kind === "post"
1002 ? normalizeQuotedEmbeds(record, context)
1003 : { normalizedEmbeds: [] as NormalizedEmbed[], unknownEmbeds: [] as UnknownEmbedEntry[] };
1004
1005 return {
1006 author,
1007 cycle: false,
1008 depth: context.depth,
1009 depthLimited: false,
1010 emptyText,
1011 facets: quotedRecordFacets(kind, record),
1012 href: buildPublicRecordHref(author, uri, kind),
1013 kind,
1014 normalizedEmbeds: normalized.normalizedEmbeds,
1015 text: quotedRecordText(kind, record),
1016 title,
1017 unknownEmbeds: normalized.unknownEmbeds,
1018 uri: quotedRecordUri(kind, uri),
1019 variant,
1020 };
1021 } finally {
1022 context.trail.delete(record);
1023 }
1024}
1025
1026function normalizedQuotedFromEmbed(embed: NormalizedEmbed): NormalizedQuotedRecord | null {
1027 if (embed.kind === "record") {
1028 return embed.quoted;
1029 }
1030 if (embed.kind === "recordWithMedia") {
1031 return embed.quoted;
1032 }
1033 return null;
1034}
1035
1036type KnownEmbedNormalizationOptions = Pick<NormalizationMeta, "explicitType" | "inferred">;
1037
1038function normalizeKnownEmbedKind(
1039 kind: EmbedCanonicalKind,
1040 record: Record<string, unknown>,
1041 context: NormalizeEmbedContext,
1042 options: KnownEmbedNormalizationOptions,
1043): Exclude<NormalizedEmbed, { kind: "unknown" }> {
1044 const { explicitType, inferred } = options;
1045
1046 if (context.source === "recordWithMedia.media" && (kind === "record" || kind === "recordWithMedia")) {
1047 return recognizedUnrenderableEmbed(
1048 context,
1049 kind,
1050 "This recognized media type is not valid in recordWithMedia.media.",
1051 record,
1052 { explicitType, inferred },
1053 );
1054 }
1055
1056 switch (kind) {
1057 case "images": {
1058 const embed = normalizeImagesEmbedView(record);
1059 if (!embed) {
1060 return recognizedUnrenderableEmbed(
1061 context,
1062 "app.bsky.embed.images#view",
1063 "Recognized image embed could not be rendered.",
1064 record,
1065 { explicitType, inferred },
1066 );
1067 }
1068
1069 return { embed, kind: "images", meta: buildMeta(context, { explicitType, inferred }) };
1070 }
1071 case "external": {
1072 const embed = normalizeExternalEmbedView(record);
1073 if (!embed) {
1074 return recognizedUnrenderableEmbed(
1075 context,
1076 "app.bsky.embed.external#view",
1077 "Recognized external embed could not be rendered.",
1078 record,
1079 { explicitType, inferred },
1080 );
1081 }
1082
1083 return { embed, kind: "external", meta: buildMeta(context, { explicitType, inferred }) };
1084 }
1085 case "video": {
1086 const embed = normalizeVideoEmbedView(record);
1087 if (!embed) {
1088 return recognizedUnrenderableEmbed(
1089 context,
1090 "app.bsky.embed.video#view",
1091 "Recognized video embed could not be rendered.",
1092 record,
1093 { explicitType, inferred },
1094 );
1095 }
1096
1097 return { embed, kind: "video", meta: buildMeta(context, { explicitType, inferred }) };
1098 }
1099 case "record": {
1100 const recordPayload = asRecord(record.record);
1101 if (!recordPayload) {
1102 return recognizedUnrenderableEmbed(
1103 context,
1104 "app.bsky.embed.record#view",
1105 "Recognized quoted record embed could not be rendered.",
1106 record,
1107 { explicitType, inferred },
1108 );
1109 }
1110
1111 return {
1112 kind: "record",
1113 meta: buildMeta(context, { explicitType, inferred }),
1114 quoted: normalizeQuotedRecord(recordPayload, childContext(context, "quoted")),
1115 };
1116 }
1117 case "recordWithMedia": {
1118 const media = record.media === undefined || record.media === null
1119 ? null
1120 : normalizeEmbedWithContext(record.media, childContext(context, "recordWithMedia.media"));
1121 const quotedRecord = normalizeQuotedRecord(
1122 recordPayloadFromRecordWithMedia(record),
1123 childContext(context, "quoted"),
1124 );
1125 return {
1126 kind: "recordWithMedia",
1127 media,
1128 meta: buildMeta(context, { explicitType, inferred }),
1129 quoted: quotedRecord,
1130 };
1131 }
1132 default: {
1133 return assertNever(kind);
1134 }
1135 }
1136}
1137
1138function normalizeEmbedWithContext(value: unknown, context: NormalizeEmbedContext): NormalizedEmbed {
1139 if (context.depth > context.maxDepth) {
1140 return recognizedUnrenderableEmbed(context, "depth-limit", "Embed nesting limit reached.", value, {
1141 depthLimited: true,
1142 });
1143 }
1144
1145 const record = asRecord(value);
1146 if (!record) {
1147 return unknownNormalizedEmbed(value, context, null, false);
1148 }
1149
1150 if (context.trail.has(record)) {
1151 return recognizedUnrenderableEmbed(context, "cycle", "Embed cycle detected.", value, { cycle: true });
1152 }
1153
1154 const explicitType = typeof record.$type === "string" ? record.$type : null;
1155 const explicitKind = canonicalEmbedKindFromType(explicitType);
1156 const inferredKind = explicitKind ? null : inferCanonicalEmbedKind(record);
1157 const kind = explicitKind ?? inferredKind;
1158 const inferred = !explicitKind && !!inferredKind;
1159 if (!kind) {
1160 return unknownNormalizedEmbed(value, context, explicitType, false);
1161 }
1162
1163 context.trail.add(record);
1164 try {
1165 return normalizeKnownEmbedKind(kind, record, context, { explicitType, inferred });
1166 } finally {
1167 context.trail.delete(record);
1168 }
1169}
1170
1171export function normalizeEmbed(value: unknown, options: NormalizeEmbedOptions = {}): NormalizedEmbed {
1172 const context: NormalizeEmbedContext = {
1173 depth: options.depth ?? 0,
1174 maxDepth: options.maxDepth ?? DEFAULT_NORMALIZE_EMBED_MAX_DEPTH,
1175 source: options.source ?? "top",
1176 trail: options.trail ?? new WeakSet<object>(),
1177 };
1178 return normalizeEmbedWithContext(value, context);
1179}
1180
1181function buildPublicRecordHref(author: Maybe<ProfileViewBasic>, uri: Maybe<string>, kind: QuotedRecordKind) {
1182 const parts = atUriParts(uri);
1183 const actor = normalizeHandle(author?.handle) ?? normalizeDid(author?.did) ?? normalizeDid(parts?.did);
1184 if (kind === "labeler") {
1185 if (!actor) {
1186 return null;
1187 }
1188 return `https://bsky.app/profile/${encodeURIComponent(actor)}`;
1189 }
1190
1191 if (kind === "post") {
1192 if (!parts?.rkey || !actor) {
1193 return null;
1194 }
1195 return `https://bsky.app/profile/${encodeURIComponent(actor)}/post/${encodeURIComponent(parts.rkey)}`;
1196 }
1197 if (kind === "feed") {
1198 if (!parts?.rkey || !actor) {
1199 return null;
1200 }
1201 return `https://bsky.app/profile/${encodeURIComponent(actor)}/feed/${encodeURIComponent(parts.rkey)}`;
1202 }
1203 if (kind === "list") {
1204 if (!parts?.rkey || !actor) {
1205 return null;
1206 }
1207 return `https://bsky.app/profile/${encodeURIComponent(actor)}/lists/${encodeURIComponent(parts.rkey)}`;
1208 }
1209 if (kind === "starter-pack") {
1210 if (!parts?.rkey) {
1211 return null;
1212 }
1213 return `https://bsky.app/starter-pack/${encodeURIComponent(parts.did)}/${encodeURIComponent(parts.rkey)}`;
1214 }
1215
1216 return null;
1217}
1218
1219function quotedRecordUri(kind: QuotedRecordKind, uri: string | null) {
1220 return kind === "post" ? uri : null;
1221}
1222
1223export function getQuotedPresentation(embed: Maybe<EmbedView>): QuotedRecordPresentation {
1224 if (!embed) {
1225 return {
1226 author: null,
1227 emptyText: "Quoted post",
1228 facets: null,
1229 href: null,
1230 kind: "post",
1231 normalizedEmbeds: [],
1232 text: null,
1233 title: "Quoted post",
1234 unknownEmbeds: [],
1235 uri: null,
1236 };
1237 }
1238
1239 const normalized = normalizeEmbed(embed, { source: "top" });
1240 const quoted = normalizedQuotedFromEmbed(normalized);
1241 if (!quoted) {
1242 return {
1243 author: null,
1244 emptyText: "Quoted post",
1245 facets: null,
1246 href: null,
1247 kind: "post",
1248 normalizedEmbeds: [],
1249 text: null,
1250 title: "Quoted post",
1251 unknownEmbeds: [],
1252 uri: null,
1253 };
1254 }
1255
1256 return toPresentation(quoted);
1257}
1258
1259export function getQuotedText(embed: Maybe<EmbedView>) {
1260 return getQuotedPresentation(embed).text;
1261}
1262
1263export function getQuotedAuthor(embed: Maybe<EmbedView>) {
1264 return getQuotedPresentation(embed).author;
1265}
1266
1267export function getQuotedUri(embed: Maybe<EmbedView>) {
1268 return getQuotedPresentation(embed).uri;
1269}
1270
1271export function getQuotedHref(embed: Maybe<EmbedView>) {
1272 return getQuotedPresentation(embed).href;
1273}
1274
1275export function patchFeedItems(items: FeedViewPost[], uri: string, updater: (post: PostView) => PostView) {
1276 return items.map((item) => (item.post.uri === uri ? { ...item, post: updater(item.post) } : item));
1277}
1278
1279export function patchThreadNode(node: ThreadNode, uri: string, updater: (post: PostView) => PostView): ThreadNode {
1280 if (node.$type !== "app.bsky.feed.defs#threadViewPost") {
1281 return node;
1282 }
1283
1284 return {
1285 ...node,
1286 parent: node.parent ? patchThreadNode(node.parent, uri, updater) : node.parent,
1287 post: node.post.uri === uri ? updater(node.post) : node.post,
1288 replies: node.replies?.map((reply) => patchThreadNode(reply, uri, updater)) ?? node.replies,
1289 };
1290}
1291
1292export function findRootPost(node: ThreadNode | null): PostView | null {
1293 if (!node || !isThreadViewPost(node)) {
1294 return null;
1295 }
1296
1297 if (node.parent && isThreadViewPost(node.parent)) {
1298 return findRootPost(node.parent) ?? node.post;
1299 }
1300
1301 return node.post;
1302}
1303
1304export function decodeThreadRouteUri(value: Maybe<string>) {
1305 if (!value) {
1306 return null;
1307 }
1308
1309 if (value.startsWith("at://")) {
1310 return value;
1311 }
1312
1313 try {
1314 const decoded = decodeURIComponent(value);
1315 return decoded.startsWith("at://") ? decoded : null;
1316 } catch {
1317 return null;
1318 }
1319}
1320
1321export function getThreadOverlayUri(search: string) {
1322 return decodeThreadRouteUri(new URLSearchParams(search).get(THREAD_QUERY_PARAM));
1323}
1324
1325export function buildThreadOverlayRoute(pathname: string, search: string, uri: string | null) {
1326 const params = new URLSearchParams(search);
1327 if (uri) {
1328 params.set(THREAD_QUERY_PARAM, uri);
1329 } else {
1330 params.delete(THREAD_QUERY_PARAM);
1331 }
1332
1333 const nextSearch = params.toString();
1334 return nextSearch ? `${pathname}?${nextSearch}` : pathname;
1335}
1336
1337export function buildPublicPostUrl(post: Pick<PostView, "author" | "uri">) {
1338 return buildPublicRecordHref(post.author, post.uri, "post") ?? post.uri;
1339}
1340
1341function normalizeHandle(value: string | null | undefined) {
1342 if (typeof value !== "string") {
1343 return null;
1344 }
1345
1346 const normalized = value.replace(/^@/, "").trim();
1347 return normalized || null;
1348}
1349
1350function normalizeDid(value: string | null | undefined) {
1351 if (typeof value !== "string") {
1352 return null;
1353 }
1354
1355 const normalized = value.trim();
1356 return normalized || null;
1357}
1358
1359export function postRkeyFromUri(uri: string | null | undefined) {
1360 return atUriParts(uri)?.rkey ?? null;
1361}