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.

feat: rich text support for writing and rendering posts

+887 -125
+61
src-tauri/src/feed.rs
··· 922 922 merge_saved_feeds_preferences, user_preferences_from_items, FeedViewPrefItem, SavedFeedItem, 923 923 }; 924 924 use jacquard::api::app_bsky::actor::{AdultContentPref, FeedViewPref, PreferencesItem}; 925 + use jacquard::api::app_bsky::richtext::facet::FacetFeaturesItem; 926 + use jacquard::richtext; 925 927 use reqwest::StatusCode; 926 928 927 929 fn adult_content_pref_item() -> PreferencesItem<'static> { ··· 1018 1020 assert!(accepts_empty_bookmark_response(StatusCode::OK, b"")); 1019 1021 assert!(!accepts_empty_bookmark_response(StatusCode::OK, b"{}")); 1020 1022 assert!(!accepts_empty_bookmark_response(StatusCode::BAD_REQUEST, b"")); 1023 + } 1024 + 1025 + #[test] 1026 + fn richtext_parse_converts_markdown_links_into_plain_text_and_link_facets() { 1027 + let rich = tokio::runtime::Runtime::new() 1028 + .expect("tokio runtime should build") 1029 + .block_on(async { 1030 + richtext::parse("[example](https://example.com)") 1031 + .build_async(&super::JacquardResolver::default()) 1032 + .await 1033 + }) 1034 + .expect("richtext should build"); 1035 + 1036 + assert_eq!(rich.text.as_ref(), "example"); 1037 + let facets = rich.facets.expect("markdown link should create a facet"); 1038 + assert_eq!(facets.len(), 1); 1039 + assert_eq!(facets[0].index.byte_start, 0); 1040 + assert_eq!(facets[0].index.byte_end, 7); 1041 + 1042 + match &facets[0].features[0] { 1043 + FacetFeaturesItem::Link(link) => assert_eq!(link.uri.as_ref(), "https://example.com"), 1044 + other => panic!("expected link facet, got {other:?}"), 1045 + } 1046 + } 1047 + 1048 + #[test] 1049 + fn richtext_parse_keeps_other_facets_after_markdown_link_normalization() { 1050 + let rich = tokio::runtime::Runtime::new() 1051 + .expect("tokio runtime should build") 1052 + .block_on(async { 1053 + richtext::parse("[example](https://example.com) #rust https://docs.rs @did:plc:alice") 1054 + .build_async(&super::JacquardResolver::default()) 1055 + .await 1056 + }) 1057 + .expect("richtext should build"); 1058 + 1059 + assert_eq!(rich.text.as_ref(), "example #rust https://docs.rs @did:plc:alice"); 1060 + let facets = rich.facets.expect("text should produce facets"); 1061 + 1062 + assert_eq!(facets.len(), 4); 1063 + assert!(matches!(facets[0].features[0], FacetFeaturesItem::Link(_))); 1064 + assert!(matches!(facets[1].features[0], FacetFeaturesItem::Tag(_))); 1065 + assert!(matches!(facets[2].features[0], FacetFeaturesItem::Link(_))); 1066 + assert!(matches!(facets[3].features[0], FacetFeaturesItem::Mention(_))); 1067 + } 1068 + 1069 + #[test] 1070 + fn richtext_parse_leaves_invalid_markdown_link_syntax_unchanged() { 1071 + let rich = tokio::runtime::Runtime::new() 1072 + .expect("tokio runtime should build") 1073 + .block_on(async { 1074 + richtext::parse("[broken](not a url") 1075 + .build_async(&super::JacquardResolver::default()) 1076 + .await 1077 + }) 1078 + .expect("richtext should build"); 1079 + 1080 + assert_eq!(rich.text.as_ref(), "[broken](not a url"); 1081 + assert!(rich.facets.is_none(), "invalid markdown should not produce facets"); 1021 1082 } 1022 1083 }
+27
src/components/explorer/views/RecordView.test.tsx
··· 31 31 expect(screen.getByText("Moderation Labels")).toBeInTheDocument(); 32 32 expect(screen.getByText("!warn")).toBeInTheDocument(); 33 33 }); 34 + 35 + it("renders post previews with rich text formatting", async () => { 36 + getRecordBacklinksMock.mockResolvedValue({ 37 + likes: { cursor: null, records: [], total: 0 }, 38 + quotes: { cursor: null, records: [], total: 0 }, 39 + replies: { cursor: null, records: [], total: 0 }, 40 + reposts: { cursor: null, records: [], total: 0 }, 41 + }); 42 + 43 + render(() => ( 44 + <RecordView 45 + record={{ 46 + $type: "app.bsky.feed.post", 47 + facets: [{ 48 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 49 + index: { byteEnd: 19, byteStart: 0 }, 50 + }], 51 + text: "https://example.com\n\n```ts\nconst reply = true;\n```", 52 + }} 53 + cid="cid-post" 54 + uri="at://did:plc:alice/app.bsky.feed.post/123" 55 + labels={[]} /> 56 + )); 57 + 58 + expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 59 + expect(screen.getByText("const reply = true;")).toBeInTheDocument(); 60 + }); 34 61 });
+44 -15
src/components/explorer/views/RecordView.tsx
··· 1 1 import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; 2 2 import { type JsonValue, JsonValueAs } from "$/components/explorer/types"; 3 3 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 4 + import { PostRichText } from "$/components/shared/PostRichText"; 4 5 import { getStringProperty, isRecordLike, isString } from "$/lib/type-guards"; 6 + import type { PostRecord } from "$/lib/types"; 5 7 import { createMemo, createSignal, For, type ParentProps, Show } from "solid-js"; 6 8 import { Motion } from "solid-motionone"; 7 9 ··· 127 129 128 130 function KnownRecordPreview(props: { record: Record<string, unknown> }) { 129 131 const kind = () => (props.record.$type as string) || ""; 130 - const content = () => (isString(props.record.text) ? props.record.text : null); 132 + const postRecord = () => props.record as PostRecord; 133 + const content = () => (isString(postRecord().text) ? postRecord().text : null); 131 134 const subject = () => { 132 135 const value = props.record.subject; 133 136 ··· 137 140 return null; 138 141 }; 139 142 143 + // return ( 144 + // <> 145 + // <Show when={kind() === "app.bsky.feed.post" && content()}> 146 + // <CollapsibleSection title="Post Preview"> 147 + // <div class="p-4 rounded-xl bg-black/30"> 148 + // <PostRichText class="text-sm" facets={postRecord().facets} text={content() ?? ""} /> 149 + // </div> 150 + // </CollapsibleSection> 151 + // </Show> 152 + 153 + // <Show when={kind() !== "app.bsky.feed.post" && subject()}> 154 + // {(value) => ( 155 + // <CollapsibleSection title="Subject"> 156 + // <div class="p-4 rounded-xl bg-black/30"> 157 + // <SubjectPreview subject={value()} /> 158 + // </div> 159 + // </CollapsibleSection> 160 + // )} 161 + // </Show> 162 + // </> 163 + // ); 164 + 140 165 return ( 141 - <> 142 - <Show when={kind() === "app.bsky.feed.post" && content()}> 143 - <CollapsibleSection title="Post Preview"> 144 - <div class="p-4 rounded-xl bg-black/30"> 145 - <p class="text-sm leading-relaxed text-on-secondary-container">{content()}</p> 146 - </div> 147 - </CollapsibleSection> 148 - </Show> 149 - 150 - <Show when={kind() !== "app.bsky.feed.post" && subject()}> 151 - {(value) => ( 152 - <CollapsibleSection title="Subject"> 166 + <Show 167 + when={kind() === "app.bsky.feed.post"} 168 + fallback={ 169 + <Show when={subject()}> 170 + {(value) => ( 171 + <CollapsibleSection title="Subject"> 172 + <div class="p-4 rounded-xl bg-black/30"> 173 + <SubjectPreview subject={value()} /> 174 + </div> 175 + </CollapsibleSection> 176 + )} 177 + </Show> 178 + }> 179 + <Show when={content()}> 180 + {value => ( 181 + <CollapsibleSection title="Post Preview"> 153 182 <div class="p-4 rounded-xl bg-black/30"> 154 - <SubjectPreview subject={value()} /> 183 + <PostRichText class="text-sm" facets={postRecord().facets} text={value()} /> 155 184 </div> 156 185 </CollapsibleSection> 157 186 )} 158 187 </Show> 159 - </> 188 + </Show> 160 189 ); 161 190 } 162 191
+8 -13
src/components/feeds/FeedComposer.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 - import { getDisplayName, getPostText } from "$/lib/feeds"; 2 + import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 3 + import { buildPublicPostUrl, getDisplayName, getPostText } from "$/lib/feeds"; 3 4 import type { PostView } from "$/lib/types"; 4 5 import { createMemo, For, Show } from "solid-js"; 5 6 import { Motion, Presence } from "solid-motionone"; ··· 298 299 return ( 299 300 <Show when={props.post}> 300 301 {(post) => ( 301 - <div class="mt-4 rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 302 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Quote preview</p> 303 - <p class="mt-2 text-sm font-semibold text-on-surface"> 304 - {getDisplayName(post().author)} 305 - <span class="ml-1 text-xs font-normal text-on-surface-variant"> 306 - @{post().author.handle.replace(/^@/, "")} 307 - </span> 308 - </p> 309 - <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container"> 310 - {getPostText(post()) || "Quoted post"} 311 - </p> 312 - </div> 302 + <QuotedPostPreview 303 + author={post().author} 304 + class="mt-4 rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 305 + href={buildPublicPostUrl(post())} 306 + text={getPostText(post()) || "Quoted post"} 307 + title="Quote preview" /> 313 308 )} 314 309 </Show> 315 310 );
+45 -3
src/components/feeds/PostCard.test.tsx
··· 8 8 cid: "cid-post", 9 9 indexedAt: "2026-03-28T12:00:00.000Z", 10 10 likeCount: 4, 11 - record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Visit https://example.com @bob.test #solid" }, 11 + record: { 12 + createdAt: "2026-03-28T12:00:00.000Z", 13 + facets: [{ 14 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 15 + index: { byteEnd: 25, byteStart: 6 }, 16 + }, { 17 + features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:bob" }], 18 + index: { byteEnd: 35, byteStart: 26 }, 19 + }, { features: [{ $type: "app.bsky.richtext.facet#tag", tag: "solid" }], index: { byteEnd: 42, byteStart: 36 } }], 20 + text: "Visit https://example.com @bob.test #solid", 21 + }, 12 22 replyCount: 2, 13 23 repostCount: 1, 14 24 uri: "at://did:plc:alice/app.bsky.feed.post/123", ··· 17 27 } 18 28 19 29 describe("PostCard", () => { 20 - it("linkifies urls and keeps mentions and hashtags visible", () => { 30 + it("renders links, mentions, and hashtags from facets", () => { 21 31 render(() => <PostCard post={createPost()} />); 22 32 23 33 expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 24 - expect(screen.getByText("@bob.test")).toBeInTheDocument(); 34 + expect(screen.getByRole("link", { name: "@bob.test" })).toHaveAttribute("href", "#/profile/did%3Aplc%3Abob"); 25 35 expect(screen.getByText("#solid")).toBeInTheDocument(); 26 36 }); 27 37 ··· 82 92 )); 83 93 84 94 expect(screen.getByText("Replying to @bob.test")).toBeInTheDocument(); 95 + }); 96 + 97 + it("renders recordWithMedia embeds as media plus quoted record", () => { 98 + render(() => ( 99 + <PostCard 100 + post={{ 101 + ...createPost(), 102 + embed: { 103 + $type: "app.bsky.embed.recordWithMedia#view", 104 + media: { 105 + $type: "app.bsky.embed.images#view", 106 + images: [{ alt: "Preview image", fullsize: "https://cdn.example.com/image.png" }], 107 + }, 108 + record: { 109 + $type: "app.bsky.embed.record#view", 110 + record: { 111 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 112 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 113 + value: { text: "Quoted body" }, 114 + }, 115 + }, 116 + }, 117 + }} /> 118 + )); 119 + 120 + expect(screen.getByAltText("Preview image")).toHaveAttribute("src", "https://cdn.example.com/image.png"); 121 + expect(screen.getByText("Quoted post")).toBeInTheDocument(); 122 + expect(screen.getByText("Quoted body")).toBeInTheDocument(); 123 + expect(screen.getByRole("link", { name: /quoted body/i })).toHaveAttribute( 124 + "href", 125 + "https://bsky.app/profile/bob.test/post/quoted", 126 + ); 85 127 }); 86 128 });
+74 -81
src/components/feeds/PostCard.tsx
··· 1 1 import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 2 2 import { Icon } from "$/components/shared/Icon"; 3 + import { PostRichText } from "$/components/shared/PostRichText"; 4 + import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 3 5 import { 4 6 buildPublicPostUrl, 5 7 formatRelativeTime, ··· 8 10 getPostCreatedAt, 9 11 getPostText, 10 12 getQuotedAuthor, 13 + getQuotedHref, 11 14 getQuotedText, 12 15 isReplyItem, 13 16 } from "$/lib/feeds"; 14 17 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 15 - import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; 18 + import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; 16 19 import { formatCount } from "$/lib/utils/text"; 17 20 import { createMemo, createSignal, For, type JSX, Match, Show, Switch } from "solid-js"; 18 21 import { Motion } from "solid-motionone"; ··· 44 47 const isLiked = createMemo(() => !!props.post.viewer?.like); 45 48 const isReposted = createMemo(() => !!props.post.viewer?.repost); 46 49 const likeCount = createMemo(() => formatCount(props.post.likeCount)); 50 + const postText = createMemo(() => getPostText(props.post)); 47 51 const replyCount = createMemo(() => formatCount(props.post.replyCount)); 48 52 const repostCount = createMemo(() => formatCount(props.post.repostCount)); 49 53 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); ··· 139 143 return ( 140 144 <article 141 145 ref={(element) => props.registerRef?.(element)} 142 - class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-3xl max-[520px]:px-3.5" 146 + class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-4 py-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-3.5 max-[760px]:py-3.5 max-[520px]:rounded-3xl max-[520px]:px-3 max-[520px]:py-3" 143 147 classList={{ 144 148 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 145 149 !!props.focused, ··· 178 182 profileHref={profileHref()} 179 183 post={props.post} /> 180 184 181 - <Show when={getPostText(props.post)}> 182 - {(text) => ( 183 - <p class="m-0 whitespace-pre-wrap wrap-break-word text-base leading-[1.65] text-on-secondary-container"> 184 - <LinkifiedText text={text()} /> 185 - </p> 186 - )} 187 - </Show> 185 + <PostBodyText facets={props.post.record.facets} text={postText()} /> 188 186 189 187 <PostEmbeds post={props.post} /> 190 188 </PostPrimaryRegion> ··· 253 251 254 252 return ( 255 253 <div 256 - class="min-w-0 rounded-2xl outline-none transition duration-150 ease-out" 254 + class="min-w-0 rounded-2xl outline-none transition duration-150 ease-out p-2" 257 255 classList={{ 258 256 "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 259 257 interactive(), ··· 359 357 ); 360 358 } 361 359 360 + function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { 361 + return ( 362 + <Show when={props.text.trim().length > 0}> 363 + <PostRichText class="m-0" facets={props.facets} text={props.text} /> 364 + </Show> 365 + ); 366 + } 367 + 362 368 function ActionButton( 363 369 props: { 364 370 active?: boolean; ··· 397 403 <Show when={props.post.embed}> 398 404 {(current) => ( 399 405 <div class="mt-4"> 400 - <Switch> 401 - <Match when={current().$type === "app.bsky.embed.images#view"}> 402 - <ImageEmbed embed={current() as ImagesEmbedView} /> 403 - </Match> 404 - <Match when={current().$type === "app.bsky.embed.external#view"}> 405 - <ExternalEmbed 406 - description={(current() as { external: { description?: string } }).external.description} 407 - thumb={(current() as { external: { thumb?: string } }).external.thumb} 408 - title={(current() as { external: { title?: string } }).external.title} 409 - uri={(current() as { external: { uri?: string } }).external.uri} /> 410 - </Match> 411 - <Match when={current().$type === "app.bsky.embed.video#view"}> 412 - <ExternalEmbed 413 - description={(current() as { alt?: string }).alt} 414 - thumb={(current() as { thumbnail?: string }).thumbnail} 415 - title="Video attachment" 416 - uri={(current() as { playlist?: string }).playlist} /> 417 - </Match> 418 - <Match 419 - when={current().$type === "app.bsky.embed.record#view" 420 - || current().$type === "app.bsky.embed.recordWithMedia#view"}> 421 - <QuoteEmbed author={getQuotedAuthor(current())} text={getQuotedText(current())} title="Quoted post" /> 422 - </Match> 423 - </Switch> 406 + <EmbedContent embed={current()} /> 424 407 </div> 425 408 )} 426 409 </Show> 427 410 ); 428 411 } 429 412 413 + function EmbedContent(props: { embed: EmbedView }) { 414 + return ( 415 + <Switch> 416 + <Match when={props.embed.$type === "app.bsky.embed.images#view"}> 417 + <ImageEmbed embed={props.embed as ImagesEmbedView} /> 418 + </Match> 419 + <Match when={props.embed.$type === "app.bsky.embed.external#view"}> 420 + <ExternalEmbed 421 + description={(props.embed as { external: { description?: string } }).external.description} 422 + thumb={(props.embed as { external: { thumb?: string } }).external.thumb} 423 + title={(props.embed as { external: { title?: string } }).external.title} 424 + uri={(props.embed as { external: { uri?: string } }).external.uri} /> 425 + </Match> 426 + <Match when={props.embed.$type === "app.bsky.embed.video#view"}> 427 + <ExternalEmbed 428 + description={(props.embed as { alt?: string }).alt} 429 + thumb={(props.embed as { thumbnail?: string }).thumbnail} 430 + title="Video attachment" 431 + uri={(props.embed as { playlist?: string }).playlist} /> 432 + </Match> 433 + <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 434 + <RecordEmbedContent embed={props.embed} /> 435 + </Match> 436 + <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 437 + <RecordWithMediaEmbedContent embed={props.embed} /> 438 + </Match> 439 + </Switch> 440 + ); 441 + } 442 + 443 + function RecordEmbedContent(props: { embed: EmbedView }) { 444 + return ( 445 + <QuoteEmbed 446 + author={getQuotedAuthor(props.embed)} 447 + href={getQuotedHref(props.embed)} 448 + text={getQuotedText(props.embed)} 449 + title="Quoted post" /> 450 + ); 451 + } 452 + 453 + function RecordWithMediaEmbedContent(props: { embed: EmbedView }) { 454 + const media = () => ("media" in props.embed ? props.embed.media : null); 455 + 456 + return ( 457 + <div class="grid gap-3"> 458 + <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} />}</Show> 459 + <QuoteEmbed 460 + author={getQuotedAuthor(props.embed)} 461 + href={getQuotedHref(props.embed)} 462 + text={getQuotedText(props.embed)} 463 + title="Quoted post" /> 464 + </div> 465 + ); 466 + } 467 + 430 468 function ImageEmbed(props: { embed: ImagesEmbedView }) { 431 469 const images = createMemo(() => props.embed.images.slice(0, 4)); 432 470 return ( ··· 472 510 ); 473 511 } 474 512 475 - function QuoteEmbed(props: { author: ProfileViewBasic | null; text?: unknown; title: string }) { 476 - const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 477 - 478 - return ( 479 - <div class="rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 480 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 481 - <Show when={props.author}> 482 - {(author) => ( 483 - <p class="mt-2 wrap-break-word text-sm font-semibold text-on-surface"> 484 - {getDisplayName(author())} 485 - <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 486 - @{author().handle.replace(/^@/, "")} 487 - </span> 488 - </p> 489 - )} 490 - </Show> 491 - <Show when={preview()}> 492 - {(text) => <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{text()}</p>} 493 - </Show> 494 - </div> 495 - ); 496 - } 497 - 498 - function LinkifiedText(props: { text: string }) { 499 - const parts = () => props.text.split(/(https?:\/\/\S+|@[a-z0-9._-]+(?:\.[a-z0-9._-]+)+|#[\p{L}\p{N}_-]+)/giu); 500 - 501 - return ( 502 - <For each={parts()}> 503 - {(part) => ( 504 - <Switch fallback={<span class="wrap-anywhere">{part}</span>}> 505 - <Match when={/^https?:\/\//i.test(part)}> 506 - <a 507 - class="break-all text-primary no-underline hover:underline" 508 - href={part} 509 - rel="noreferrer" 510 - target="_blank" 511 - onClick={(event) => event.stopPropagation()}> 512 - {part} 513 - </a> 514 - </Match> 515 - <Match when={/^[@#]/.test(part)}> 516 - <span class="break-all text-primary">{part}</span> 517 - </Match> 518 - </Switch> 519 - )} 520 - </For> 521 - ); 513 + function QuoteEmbed(props: { author: ProfileViewBasic | null; href?: string | null; text?: unknown; title: string }) { 514 + return <QuotedPostPreview author={props.author} href={props.href} text={props.text} title={props.title} />; 522 515 } 523 516 524 517 function isInteractiveTarget(target: EventTarget | null) {
+102
src/components/feeds/useFeedWorkspaceController.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { useFeedWorkspaceController } from "./useFeedWorkspaceController"; 4 + 5 + const invokeMock = vi.hoisted(() => vi.fn()); 6 + const listenMock = vi.hoisted(() => vi.fn()); 7 + 8 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 9 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 10 + 11 + const ACTIVE_SESSION = { did: "did:plc:alice", handle: "alice.test" } as const; 12 + const SAMPLE_POST = { 13 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 14 + cid: "cid-bob", 15 + indexedAt: "2026-03-28T12:00:00.000Z", 16 + likeCount: 0, 17 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Sample" }, 18 + replyCount: 0, 19 + repostCount: 0, 20 + uri: "at://did:plc:bob/app.bsky.feed.post/post-1", 21 + viewer: {}, 22 + } as const; 23 + 24 + function ControllerHarness() { 25 + const controller = useFeedWorkspaceController({ 26 + activeSession: ACTIVE_SESSION, 27 + onError: () => {}, 28 + onOpenThread: () => {}, 29 + }); 30 + 31 + return ( 32 + <div> 33 + <button type="button" onClick={() => controller.openReplyComposer(SAMPLE_POST, SAMPLE_POST)}>Reply</button> 34 + <button type="button" onClick={() => controller.openQuoteComposer(SAMPLE_POST)}>Quote</button> 35 + <button type="button" onClick={controller.clearReplyComposer}>Clear reply</button> 36 + <button type="button" onClick={controller.clearQuoteComposer}>Clear quote</button> 37 + <button type="button" onClick={() => void controller.submitPost()}>Submit</button> 38 + <p data-testid="active-feed">{controller.workspace.activeFeedId ?? "none"}</p> 39 + <p data-testid="reply-state">{controller.workspace.composer.replyTarget ? "on" : "off"}</p> 40 + <p data-testid="quote-state">{controller.workspace.composer.quoteTarget ? "on" : "off"}</p> 41 + </div> 42 + ); 43 + } 44 + 45 + describe("useFeedWorkspaceController", () => { 46 + it("keeps reply and quote state together and submits both", async () => { 47 + invokeMock.mockReset(); 48 + listenMock.mockReset(); 49 + listenMock.mockResolvedValue(() => {}); 50 + invokeMock.mockImplementation((command: string) => { 51 + if (command === "get_preferences") { 52 + return Promise.resolve({ 53 + savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], 54 + feedViewPrefs: [], 55 + }); 56 + } 57 + 58 + if (command === "get_timeline") { 59 + return Promise.resolve({ cursor: null, feed: [] }); 60 + } 61 + 62 + if (command === "create_post") { 63 + return Promise.resolve({ cid: "cid-created", uri: "at://did:plc:alice/app.bsky.feed.post/new-post" }); 64 + } 65 + 66 + throw new Error(`unexpected invoke: ${command}`); 67 + }); 68 + 69 + render(() => <ControllerHarness />); 70 + 71 + await screen.findByText("following"); 72 + 73 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 74 + fireEvent.click(screen.getByRole("button", { name: "Quote" })); 75 + 76 + expect(screen.getByTestId("reply-state")).toHaveTextContent("on"); 77 + expect(screen.getByTestId("quote-state")).toHaveTextContent("on"); 78 + 79 + fireEvent.click(screen.getByRole("button", { name: "Clear quote" })); 80 + expect(screen.getByTestId("reply-state")).toHaveTextContent("on"); 81 + expect(screen.getByTestId("quote-state")).toHaveTextContent("off"); 82 + 83 + fireEvent.click(screen.getByRole("button", { name: "Quote" })); 84 + fireEvent.click(screen.getByRole("button", { name: "Clear reply" })); 85 + expect(screen.getByTestId("reply-state")).toHaveTextContent("off"); 86 + expect(screen.getByTestId("quote-state")).toHaveTextContent("on"); 87 + 88 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 89 + fireEvent.click(screen.getByRole("button", { name: "Submit" })); 90 + 91 + await waitFor(() => { 92 + expect(invokeMock).toHaveBeenCalledWith("create_post", { 93 + embed: { record: { cid: SAMPLE_POST.cid, uri: SAMPLE_POST.uri }, type: "record" }, 94 + replyTo: { 95 + parent: { cid: SAMPLE_POST.cid, uri: SAMPLE_POST.uri }, 96 + root: { cid: SAMPLE_POST.cid, uri: SAMPLE_POST.uri }, 97 + }, 98 + text: "", 99 + }); 100 + }); 101 + }); 102 + });
+2 -8
src/components/feeds/useFeedWorkspaceController.ts
··· 408 408 } 409 409 410 410 function openReplyComposer(post: PostView, root: PostView) { 411 - setWorkspace( 412 - "composer", 413 - (current) => ({ ...current, open: true, quoteTarget: null, replyRoot: root, replyTarget: post }), 414 - ); 411 + setWorkspace("composer", (current) => ({ ...current, open: true, replyRoot: root, replyTarget: post })); 415 412 } 416 413 417 414 function openQuoteComposer(post: PostView) { 418 - setWorkspace( 419 - "composer", 420 - (current) => ({ ...current, open: true, quoteTarget: post, replyRoot: null, replyTarget: null }), 421 - ); 415 + setWorkspace("composer", (current) => ({ ...current, open: true, quoteTarget: post })); 422 416 } 423 417 424 418 function clearQuoteComposer() {
+43
src/components/shared/PostRichText.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { PostRichText } from "./PostRichText"; 4 + 5 + describe("PostRichText", () => { 6 + it("renders link, mention, and tag facets", () => { 7 + render(() => ( 8 + <PostRichText 9 + facets={[{ 10 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 11 + index: { byteEnd: 25, byteStart: 6 }, 12 + }, { 13 + features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:bob" }], 14 + index: { byteEnd: 35, byteStart: 26 }, 15 + }, { 16 + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "solid" }], 17 + index: { byteEnd: 42, byteStart: 36 }, 18 + }]} 19 + text="Visit https://example.com @bob.test #solid" /> 20 + )); 21 + 22 + expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 23 + expect(screen.getByRole("link", { name: "@bob.test" })).toHaveAttribute("href", "#/profile/did%3Aplc%3Abob"); 24 + expect(screen.getByText("#solid")).toBeInTheDocument(); 25 + }); 26 + 27 + it("renders markdown blocks and does not linkify inside code", () => { 28 + render(() => ( 29 + <PostRichText 30 + facets={[{ 31 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 32 + index: { byteEnd: 27, byteStart: 8 }, 33 + }]} 34 + text={"Inline `https://example.com`\n\n> quoted line\n\n```ts\nconst url = 'https://example.com';\n```"} /> 35 + )); 36 + 37 + expect(screen.queryByRole("link", { name: "https://example.com" })).not.toBeInTheDocument(); 38 + expect(screen.getByText("https://example.com", { selector: "code" })).toBeInTheDocument(); 39 + expect(screen.getByText("quoted line")).toBeInTheDocument(); 40 + expect(screen.getByText("ts")).toBeInTheDocument(); 41 + expect(screen.getByText("const url = 'https://example.com';")).toBeInTheDocument(); 42 + }); 43 + });
+192
src/components/shared/PostRichText.tsx
··· 1 + import { 2 + parsePostRichText, 3 + type ResolvedRichTextFacet, 4 + resolveRichTextFacets, 5 + type RichTextBlock, 6 + type RichTextInlineSegment, 7 + type RichTextLine, 8 + splitLegacyUrls, 9 + } from "$/lib/post-rich-text"; 10 + import { buildProfileRoute } from "$/lib/profile"; 11 + import type { RichTextFacet } from "$/lib/types"; 12 + import { For, type JSX, Show } from "solid-js"; 13 + 14 + type PostRichTextProps = { class?: string; facets?: RichTextFacet[] | null; text: string }; 15 + 16 + type TextSegmentProps = { 17 + facets: ResolvedRichTextFacet[]; 18 + hasFacets: boolean; 19 + text: string; 20 + textEnd: number; 21 + textStart: number; 22 + }; 23 + 24 + export function PostRichText(props: PostRichTextProps) { 25 + const blocks = () => parsePostRichText(props.text); 26 + const facets = () => resolveRichTextFacets(props.text, props.facets); 27 + const hasFacets = () => facets().length > 0; 28 + 29 + return ( 30 + <div class={props.class}> 31 + <For each={blocks()}> 32 + {(block, index) => ( 33 + <> 34 + <Show when={index() > 0}> 35 + <div class="h-4" /> 36 + </Show> 37 + {renderBlock(block, props.text, facets(), hasFacets())} 38 + </> 39 + )} 40 + </For> 41 + </div> 42 + ); 43 + } 44 + 45 + function renderBlock(block: RichTextBlock, text: string, facets: ResolvedRichTextFacet[], hasFacets: boolean) { 46 + if (block.kind === "paragraph") { 47 + return <TextBlock facets={facets} hasFacets={hasFacets} lines={block.lines} text={text} />; 48 + } 49 + 50 + if (block.kind === "blockquote") { 51 + return ( 52 + <blockquote class="m-0 rounded-r-2xl border-l-2 border-primary/40 bg-white/3 px-4 py-3"> 53 + <TextBlock facets={facets} hasFacets={hasFacets} lines={block.lines} text={text} /> 54 + </blockquote> 55 + ); 56 + } 57 + 58 + return ( 59 + <pre class="m-0 overflow-x-auto rounded-2xl bg-black/45 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 60 + <Show when={block.language}> 61 + {(language) => <p class="mb-3 mt-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{language()}</p>} 62 + </Show> 63 + <code class="block whitespace-pre-wrap wrap-break-word font-mono text-[0.92rem] leading-[1.65] text-on-secondary-container"> 64 + {block.code} 65 + </code> 66 + </pre> 67 + ); 68 + } 69 + 70 + function TextBlock( 71 + props: { facets: ResolvedRichTextFacet[]; hasFacets: boolean; lines: RichTextLine[]; text: string }, 72 + ) { 73 + return ( 74 + <p class="m-0 whitespace-pre-wrap wrap-break-word text-base leading-[1.65] text-on-secondary-container"> 75 + <For each={props.lines}> 76 + {(line, lineIndex) => ( 77 + <> 78 + <Show when={lineIndex() > 0}> 79 + <br /> 80 + </Show> 81 + <For each={line.segments}> 82 + {(segment) => renderLineSegment(segment, props.text, props.facets, props.hasFacets)} 83 + </For> 84 + </> 85 + )} 86 + </For> 87 + </p> 88 + ); 89 + } 90 + 91 + function renderLineSegment( 92 + segment: RichTextInlineSegment, 93 + text: string, 94 + facets: ResolvedRichTextFacet[], 95 + hasFacets: boolean, 96 + ) { 97 + if (segment.kind === "code") { 98 + return ( 99 + <code class="rounded-md bg-black/45 px-1.5 py-0.5 font-mono text-[0.92em] text-on-surface">{segment.text}</code> 100 + ); 101 + } 102 + 103 + return ( 104 + <TextSegment facets={facets} hasFacets={hasFacets} text={text} textEnd={segment.end} textStart={segment.start} /> 105 + ); 106 + } 107 + 108 + function TextSegment(props: TextSegmentProps) { 109 + const content = () => props.text.slice(props.textStart, props.textEnd); 110 + const relevantFacets = () => 111 + props.facets.filter((facet) => facet.start >= props.textStart && facet.end <= props.textEnd); 112 + 113 + return ( 114 + <> 115 + <Show 116 + when={relevantFacets().length > 0} 117 + fallback={<LegacyText text={content()} useFallback={!props.hasFacets} />}> 118 + <For each={buildFacetNodes(content(), props.textStart, relevantFacets())}>{(node) => node}</For> 119 + </Show> 120 + </> 121 + ); 122 + } 123 + 124 + function buildFacetNodes(text: string, offset: number, facets: ResolvedRichTextFacet[]) { 125 + const nodes: JSX.Element[] = []; 126 + let cursor = offset; 127 + 128 + for (const facet of facets) { 129 + if (facet.start > cursor) { 130 + nodes.push(<span class="wrap-anywhere">{text.slice(cursor - offset, facet.start - offset)}</span>); 131 + } 132 + 133 + const label = text.slice(facet.start - offset, facet.end - offset); 134 + nodes.push(renderFacetNode(facet, label)); 135 + cursor = facet.end; 136 + } 137 + 138 + if (cursor < offset + text.length) { 139 + nodes.push(<span class="wrap-anywhere">{text.slice(cursor - offset)}</span>); 140 + } 141 + 142 + return nodes; 143 + } 144 + 145 + function renderFacetNode(facet: ResolvedRichTextFacet, label: string) { 146 + if (facet.feature.$type === "app.bsky.richtext.facet#link") { 147 + return ( 148 + <a 149 + class="break-all text-primary no-underline hover:underline" 150 + href={facet.feature.uri} 151 + rel="noreferrer" 152 + target="_blank" 153 + onClick={(event) => event.stopPropagation()}> 154 + {label} 155 + </a> 156 + ); 157 + } 158 + 159 + if (facet.feature.$type === "app.bsky.richtext.facet#mention") { 160 + return ( 161 + <a 162 + class="break-all text-primary no-underline hover:underline" 163 + href={`#${buildProfileRoute(facet.feature.did)}`} 164 + onClick={(event) => event.stopPropagation()}> 165 + {label} 166 + </a> 167 + ); 168 + } 169 + 170 + return <span class="break-all text-primary">{label}</span>; 171 + } 172 + 173 + function LegacyText(props: { text: string; useFallback: boolean }) { 174 + return ( 175 + <Show when={props.useFallback} fallback={<span class="wrap-anywhere">{props.text}</span>}> 176 + <For each={splitLegacyUrls(props.text)}> 177 + {(part) => ( 178 + <Show when={part.kind === "url"} fallback={<span class="wrap-anywhere">{part.text}</span>}> 179 + <a 180 + class="break-all text-primary no-underline hover:underline" 181 + href={part.text} 182 + rel="noreferrer" 183 + target="_blank" 184 + onClick={(event) => event.stopPropagation()}> 185 + {part.text} 186 + </a> 187 + </Show> 188 + )} 189 + </For> 190 + </Show> 191 + ); 192 + }
+51
src/components/shared/QuotedPostPreview.tsx
··· 1 + import { getDisplayName } from "$/lib/feeds"; 2 + import type { ProfileViewBasic } from "$/lib/types"; 3 + import { createMemo, Show } from "solid-js"; 4 + 5 + export function QuotedPostPreview( 6 + props: { author: ProfileViewBasic | null; class?: string; href?: string | null; text?: unknown; title: string }, 7 + ) { 8 + const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 9 + 10 + return ( 11 + <div class={props.class ?? "rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"}> 12 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 13 + <Show 14 + when={props.href} 15 + fallback={<QuotedPreviewContent author={props.author} preview={preview()} />}> 16 + {(href) => ( 17 + <a 18 + class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-white/4" 19 + href={href()} 20 + rel="noreferrer" 21 + target="_blank" 22 + onClick={(event) => event.stopPropagation()}> 23 + <QuotedPreviewContent author={props.author} preview={preview()} /> 24 + </a> 25 + )} 26 + </Show> 27 + </div> 28 + ); 29 + } 30 + 31 + function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string }) { 32 + return ( 33 + <> 34 + <Show when={props.author}> 35 + {(author) => ( 36 + <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface"> 37 + {getDisplayName(author())} 38 + <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 39 + @{author().handle.replace(/^@/, "")} 40 + </span> 41 + </p> 42 + )} 43 + </Show> 44 + <Show 45 + when={props.preview} 46 + fallback={<p class="mt-2 text-sm leading-[1.55] text-on-surface-variant">Quoted post</p>}> 47 + {(text) => <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{text()}</p>} 48 + </Show> 49 + </> 50 + ); 51 + }
+17 -4
src/lib/feeds.ts
··· 100 100 101 101 export function getPostText(post: PostView) { 102 102 const text = post.record.text; 103 - return typeof text === "string" ? text.trim() : ""; 103 + return typeof text === "string" ? text : ""; 104 104 } 105 105 106 106 export function getPostCreatedAt(post: PostView) { ··· 316 316 return getQuotedRecord(embed)?.author ?? null; 317 317 } 318 318 319 + export function getQuotedHref(embed: Maybe<EmbedView>) { 320 + const record = getQuotedRecord(embed); 321 + return buildPublicPostHref(record?.author ?? null, record?.uri); 322 + } 323 + 319 324 export function patchFeedItems(items: FeedViewPost[], uri: string, updater: (post: PostView) => PostView) { 320 325 return items.map((item) => (item.post.uri === uri ? { ...item, post: updater(item.post) } : item)); 321 326 } ··· 383 388 } 384 389 385 390 export function buildPublicPostUrl(post: Pick<PostView, "author" | "uri">) { 386 - const handle = post.author.handle.replace(/^@/, "").trim(); 387 - const segments = post.uri.split("/"); 391 + return buildPublicPostHref(post.author, post.uri) ?? post.uri; 392 + } 393 + 394 + export function buildPublicPostHref(author: Maybe<ProfileViewBasic>, uri: Maybe<string>) { 395 + if (!author || typeof uri !== "string") { 396 + return null; 397 + } 398 + 399 + const handle = author.handle.replace(/^@/, "").trim(); 400 + const segments = uri.split("/"); 388 401 const rkey = segments.at(-1)?.trim(); 389 402 390 403 if (handle && rkey) { 391 404 return `https://bsky.app/profile/${encodeURIComponent(handle)}/post/${encodeURIComponent(rkey)}`; 392 405 } 393 406 394 - return post.uri; 407 + return null; 395 408 }
+208
src/lib/post-rich-text.ts
··· 1 + import type { RichTextFacet, RichTextFacetFeature } from "./types"; 2 + 3 + export type ResolvedRichTextFacet = { end: number; feature: RichTextFacetFeature; start: number }; 4 + 5 + export type RichTextInlineSegment = { end: number; kind: "text"; start: number } | { kind: "code"; text: string }; 6 + 7 + export type RichTextLine = { segments: RichTextInlineSegment[] }; 8 + 9 + export type RichTextBlock = { kind: "blockquote"; lines: RichTextLine[] } | { 10 + code: string; 11 + kind: "codeBlock"; 12 + language: string | null; 13 + } | { kind: "paragraph"; lines: RichTextLine[] }; 14 + 15 + const URL_REGEX = /https?:\/\/\S+/giu; 16 + 17 + export function parsePostRichText(text: string): RichTextBlock[] { 18 + const blocks: RichTextBlock[] = []; 19 + const lines = getLineEntries(text); 20 + let index = 0; 21 + 22 + while (index < lines.length) { 23 + const line = lines[index]; 24 + 25 + if (isFenceLine(line.text)) { 26 + const language = line.text.slice(3).trim() || null; 27 + const codeLines: string[] = []; 28 + index += 1; 29 + 30 + while (index < lines.length && !isFenceLine(lines[index].text)) { 31 + codeLines.push(lines[index].text); 32 + index += 1; 33 + } 34 + 35 + if (index < lines.length && isFenceLine(lines[index].text)) { 36 + index += 1; 37 + } 38 + 39 + blocks.push({ code: codeLines.join("\n"), kind: "codeBlock", language }); 40 + continue; 41 + } 42 + 43 + if (line.text.trim() === "") { 44 + index += 1; 45 + continue; 46 + } 47 + 48 + if (line.text.startsWith(">")) { 49 + const quoteLines: RichTextLine[] = []; 50 + 51 + while (index < lines.length && lines[index].text.startsWith(">")) { 52 + const current = lines[index]; 53 + const markerLength = current.text.startsWith("> ") ? 2 : 1; 54 + quoteLines.push(parseInlineSegments(current.text.slice(markerLength), current.start + markerLength)); 55 + index += 1; 56 + } 57 + 58 + blocks.push({ kind: "blockquote", lines: quoteLines }); 59 + continue; 60 + } 61 + 62 + const paragraphLines: RichTextLine[] = []; 63 + 64 + while (index < lines.length) { 65 + const current = lines[index]; 66 + if (current.text.trim() === "" || current.text.startsWith(">") || isFenceLine(current.text)) { 67 + break; 68 + } 69 + 70 + paragraphLines.push(parseInlineSegments(current.text, current.start)); 71 + index += 1; 72 + } 73 + 74 + blocks.push({ kind: "paragraph", lines: paragraphLines }); 75 + } 76 + 77 + return blocks.length > 0 ? blocks : [{ kind: "paragraph", lines: [parseInlineSegments("", 0)] }]; 78 + } 79 + 80 + export function resolveRichTextFacets( 81 + text: string, 82 + facets: RichTextFacet[] | null | undefined, 83 + ): ResolvedRichTextFacet[] { 84 + if (!facets || facets.length === 0) { 85 + return []; 86 + } 87 + 88 + const byteOffsets = buildUtf8BoundaryMap(text); 89 + const resolved: ResolvedRichTextFacet[] = []; 90 + 91 + for (const facet of facets) { 92 + const start = byteOffsets.get(facet.index.byteStart); 93 + const end = byteOffsets.get(facet.index.byteEnd); 94 + const feature = facet.features.find(isSupportedFacetFeature); 95 + 96 + if (start === undefined || end === undefined || start >= end || !feature) { 97 + continue; 98 + } 99 + 100 + resolved.push({ end, feature, start }); 101 + } 102 + 103 + return resolved.toSorted((left, right) => left.start - right.start || left.end - right.end); 104 + } 105 + 106 + export function splitLegacyUrls(text: string) { 107 + const parts: Array<{ kind: "text" | "url"; text: string }> = []; 108 + let lastIndex = 0; 109 + 110 + for (const match of text.matchAll(URL_REGEX)) { 111 + const url = match[0]; 112 + const start = match.index ?? 0; 113 + 114 + if (start > lastIndex) { 115 + parts.push({ kind: "text", text: text.slice(lastIndex, start) }); 116 + } 117 + 118 + parts.push({ kind: "url", text: url }); 119 + lastIndex = start + url.length; 120 + } 121 + 122 + if (lastIndex < text.length) { 123 + parts.push({ kind: "text", text: text.slice(lastIndex) }); 124 + } 125 + 126 + return parts.length > 0 ? parts : [{ kind: "text" as const, text }]; 127 + } 128 + 129 + function buildUtf8BoundaryMap(text: string) { 130 + const offsets = new Map<number, number>(); 131 + const encoder = new TextEncoder(); 132 + let byteOffset = 0; 133 + let codeUnitOffset = 0; 134 + 135 + offsets.set(0, 0); 136 + 137 + for (const char of text) { 138 + byteOffset += encoder.encode(char).length; 139 + codeUnitOffset += char.length; 140 + offsets.set(byteOffset, codeUnitOffset); 141 + } 142 + 143 + return offsets; 144 + } 145 + 146 + function getLineEntries(text: string) { 147 + const lines: Array<{ start: number; text: string }> = []; 148 + let start = 0; 149 + 150 + while (start <= text.length) { 151 + const newlineIndex = text.indexOf("\n", start); 152 + if (newlineIndex === -1) { 153 + lines.push({ start, text: text.slice(start) }); 154 + break; 155 + } 156 + 157 + lines.push({ start, text: text.slice(start, newlineIndex) }); 158 + start = newlineIndex + 1; 159 + } 160 + 161 + return lines; 162 + } 163 + 164 + function isFenceLine(line: string) { 165 + return line.startsWith("```"); 166 + } 167 + 168 + function isSupportedFacetFeature(feature: RichTextFacetFeature | undefined): feature is RichTextFacetFeature { 169 + return feature !== undefined; 170 + } 171 + 172 + function parseInlineSegments(text: string, offset: number): RichTextLine { 173 + const segments: RichTextInlineSegment[] = []; 174 + let cursor = 0; 175 + 176 + while (cursor < text.length) { 177 + const opener = text.indexOf("`", cursor); 178 + if (opener === -1) { 179 + pushTextSegment(segments, offset + cursor, offset + text.length); 180 + break; 181 + } 182 + 183 + pushTextSegment(segments, offset + cursor, offset + opener); 184 + const closer = text.indexOf("`", opener + 1); 185 + 186 + if (closer === -1) { 187 + pushTextSegment(segments, offset + opener, offset + text.length); 188 + break; 189 + } 190 + 191 + segments.push({ kind: "code", text: text.slice(opener + 1, closer) }); 192 + cursor = closer + 1; 193 + } 194 + 195 + if (segments.length === 0) { 196 + segments.push({ end: offset, kind: "text", start: offset }); 197 + } 198 + 199 + return { segments }; 200 + } 201 + 202 + function pushTextSegment(segments: RichTextInlineSegment[], start: number, end: number) { 203 + if (start >= end) { 204 + return; 205 + } 206 + 207 + segments.push({ end, kind: "text", start }); 208 + }
+13 -1
src/lib/types.ts
··· 84 84 $type?: string; 85 85 createdAt?: string; 86 86 embed?: Record<string, unknown> | null; 87 - facets?: unknown[] | null; 87 + facets?: RichTextFacet[] | null; 88 88 text?: string; 89 89 [key: string]: unknown; 90 90 }; 91 + 92 + export type RichTextByteSlice = { byteEnd: number; byteStart: number }; 93 + 94 + export type RichTextLinkFacet = { $type: "app.bsky.richtext.facet#link"; uri: string }; 95 + 96 + export type RichTextMentionFacet = { $type: "app.bsky.richtext.facet#mention"; did: string }; 97 + 98 + export type RichTextTagFacet = { $type: "app.bsky.richtext.facet#tag"; tag: string }; 99 + 100 + export type RichTextFacetFeature = RichTextLinkFacet | RichTextMentionFacet | RichTextTagFacet; 101 + 102 + export type RichTextFacet = { features: RichTextFacetFeature[]; index: RichTextByteSlice }; 91 103 92 104 type ImageEmbed = { alt?: string; aspectRatio?: { height: number; width: number }; fullsize?: string; thumb?: string }; 93 105