BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1361 lines 40 kB view raw
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}