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: profile bio link parsing via linkify

+355 -52
+1
CHANGELOG.md
··· 5 5 ### 2025-04-15 6 6 7 7 - Open external links in your default browser 8 + - Parse links, mentions, and hashtags in profile bios/`app.bsky.actor.profile.description` field 8 9 9 10 ### 2025-04-12 10 11
+4
package.json
··· 17 17 }, 18 18 "license": "MIT", 19 19 "dependencies": { 20 + "@atproto/syntax": "^0.5.4", 20 21 "@fontsource-variable/google-sans": "^5.2.1", 21 22 "@solidjs/router": "^0.16.1", 22 23 "@tauri-apps/api": "^2", ··· 26 27 "@tauri-apps/plugin-notification": "~2.3.3", 27 28 "@tauri-apps/plugin-opener": "^2", 28 29 "hls.js": "^1.6.15", 30 + "linkify-plugin-hashtag": "^4.3.2", 31 + "linkify-plugin-mention": "^4.3.2", 32 + "linkifyjs": "^4.3.2", 29 33 "solid-js": "^1.9.3", 30 34 "solid-motionone": "^1.0.4" 31 35 },
+42
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/syntax': 12 + specifier: ^0.5.4 13 + version: 0.5.4 11 14 '@fontsource-variable/google-sans': 12 15 specifier: ^5.2.1 13 16 version: 5.2.1 ··· 35 38 hls.js: 36 39 specifier: ^1.6.15 37 40 version: 1.6.15 41 + linkify-plugin-hashtag: 42 + specifier: ^4.3.2 43 + version: 4.3.2(linkifyjs@4.3.2) 44 + linkify-plugin-mention: 45 + specifier: ^4.3.2 46 + version: 4.3.2(linkifyjs@4.3.2) 47 + linkifyjs: 48 + specifier: ^4.3.2 49 + version: 4.3.2 38 50 solid-js: 39 51 specifier: ^1.9.3 40 52 version: 1.9.12 ··· 144 156 145 157 '@asamuzakjp/nwsapi@2.3.9': 146 158 resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} 159 + 160 + '@atproto/syntax@0.5.4': 161 + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} 147 162 148 163 '@babel/code-frame@7.29.0': 149 164 resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} ··· 1952 1967 resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} 1953 1968 engines: {node: '>= 12.0.0'} 1954 1969 1970 + linkify-plugin-hashtag@4.3.2: 1971 + resolution: {integrity: sha512-04geGSNuWTeMUJZdje0HvjZqN+cKbyKu1fV3R51TCNIjqnR+vXza5M5G/sBfL5WzOppVoas4XGoFCfv63oGglw==} 1972 + peerDependencies: 1973 + linkifyjs: ^4.0.0 1974 + 1975 + linkify-plugin-mention@4.3.2: 1976 + resolution: {integrity: sha512-pPkhuF1iR3on649dg65trgfKVrl0Jjhksyd2eGttpIBJFlGu7s6ZV5mA8fDBuketdkX7P9om3mKtjfMeDAbjsA==} 1977 + peerDependencies: 1978 + linkifyjs: ^4.0.0 1979 + 1980 + linkifyjs@4.3.2: 1981 + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} 1982 + 1955 1983 locate-path@5.0.0: 1956 1984 resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} 1957 1985 engines: {node: '>=8'} ··· 2711 2739 lru-cache: 11.2.7 2712 2740 2713 2741 '@asamuzakjp/nwsapi@2.3.9': {} 2742 + 2743 + '@atproto/syntax@0.5.4': 2744 + dependencies: 2745 + tslib: 2.8.1 2714 2746 2715 2747 '@babel/code-frame@7.29.0': 2716 2748 dependencies: ··· 4698 4730 lightningcss-linux-x64-musl: 1.32.0 4699 4731 lightningcss-win32-arm64-msvc: 1.32.0 4700 4732 lightningcss-win32-x64-msvc: 1.32.0 4733 + 4734 + linkify-plugin-hashtag@4.3.2(linkifyjs@4.3.2): 4735 + dependencies: 4736 + linkifyjs: 4.3.2 4737 + 4738 + linkify-plugin-mention@4.3.2(linkifyjs@4.3.2): 4739 + dependencies: 4740 + linkifyjs: 4.3.2 4741 + 4742 + linkifyjs@4.3.2: {} 4701 4743 4702 4744 locate-path@5.0.0: 4703 4745 dependencies:
+2 -15
src/components/feeds/embeds/ExternalEmbed.tsx
··· 1 - import { normalizeError } from "$/lib/utils/text"; 2 - import * as logger from "@tauri-apps/plugin-log"; 3 - import { openUrl } from "@tauri-apps/plugin-opener"; 1 + import { openExternalUrlFromEvent } from "$/lib/external-url"; 4 2 import { Show } from "solid-js"; 5 3 6 4 export function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 7 5 function handleClick(event: MouseEvent) { 8 - event.stopPropagation(); 9 - 10 - const uri = props.uri?.trim(); 11 - if (!uri) { 12 - event.preventDefault(); 13 - return; 14 - } 15 - 16 - event.preventDefault(); 17 - void openUrl(uri).catch((error) => { 18 - logger.warn("failed to open external embed URL", { keyValues: { error: normalizeError(error), uri } }); 19 - }); 6 + openExternalUrlFromEvent(event, props.uri, "external-embed"); 20 7 } 21 8 22 9 return (
+17 -6
src/components/profile/ProfileHero.tsx
··· 2 2 import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 3 import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 4 4 import { Icon } from "$/components/shared/Icon"; 5 + import { PostRichText } from "$/components/shared/PostRichText"; 6 + import { openExternalUrlFromEvent } from "$/lib/external-url"; 5 7 import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 6 8 import { collectModerationLabels } from "$/lib/moderation"; 7 - import type { ModerationLabel, ModerationUiDecision, ProfileViewDetailed } from "$/lib/types"; 9 + import type { ModerationLabel, ModerationUiDecision, ProfileViewDetailed, RichTextFacet } from "$/lib/types"; 8 10 import { formatCount } from "$/lib/utils/text"; 9 11 import { createMemo, For, Show } from "solid-js"; 10 12 ··· 77 79 } 78 80 79 81 function ProfileIdentity( 80 - props: { description: string | null; displayName: string; handle: string; viewLabel: string }, 82 + props: { 83 + description: string | null; 84 + descriptionFacets?: RichTextFacet[] | null; 85 + displayName: string; 86 + handle: string; 87 + viewLabel: string; 88 + }, 81 89 ) { 82 90 return ( 83 91 <div class="grid min-w-0 flex-1 gap-3"> ··· 90 98 </div> 91 99 <Show when={props.description}> 92 100 {(description) => ( 93 - <p class="m-0 max-w-3xl whitespace-pre-wrap text-[0.98rem] leading-[1.7] text-on-secondary-container"> 94 - {description()} 95 - </p> 101 + <PostRichText 102 + class="m-0 max-w-3xl text-[0.98rem] leading-[1.7] text-on-secondary-container" 103 + facets={props.descriptionFacets} 104 + text={description()} /> 96 105 )} 97 106 </Show> 98 107 </div> ··· 129 138 class="inline-flex items-center gap-2 text-primary no-underline transition hover:text-on-surface" 130 139 href={website()} 131 140 rel="noreferrer" 132 - target="_blank"> 141 + target="_blank" 142 + onClick={(event) => openExternalUrlFromEvent(event, website(), "profile-website-link")}> 133 143 <Icon iconClass="i-ri-link" class="text-base" /> 134 144 <span>{website().replace(/^https?:\/\//, "")}</span> 135 145 </a> ··· 255 265 256 266 <ProfileIdentity 257 267 description={props.profile.description ?? null} 268 + descriptionFacets={props.profile.descriptionFacets} 258 269 displayName={displayName()} 259 270 handle={props.profile.handle} 260 271 viewLabel={props.viewLabel} />
+36
src/components/profile/tests/ProfilePanel.test.tsx
··· 1 + import { buildProfileRoute } from "$/lib/profile"; 2 + import { buildHashtagRoute } from "$/lib/search-routes"; 1 3 import { AppTestProviders } from "$/test/providers"; 2 4 import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 5 import { beforeEach, describe, expect, it, vi } from "vitest"; ··· 173 175 174 176 expect(await screen.findByText("Current account")).toBeInTheDocument(); 175 177 expect(await screen.findByText(/my-label/i)).toBeInTheDocument(); 178 + }); 179 + 180 + it("renders profile bio links, mentions, and hashtags", async () => { 181 + getProfileMock.mockResolvedValueOnce({ 182 + status: "available", 183 + profile: { 184 + ...createProfile(), 185 + description: 186 + "A sincere engineer from #Austin building\n\n@flipper.social\n@lazurite.stormlightlabs.org\n@stormlightlabs.org\n\nhttps://github.com/sponsors/desertthunder/", 187 + }, 188 + }); 189 + 190 + renderProfilePanel(); 191 + 192 + expect(await screen.findByRole("link", { name: "#Austin" })).toHaveAttribute( 193 + "href", 194 + `#${buildHashtagRoute("Austin")}`, 195 + ); 196 + expect(screen.getByRole("link", { name: "@flipper.social" })).toHaveAttribute( 197 + "href", 198 + `#${buildProfileRoute("flipper.social")}`, 199 + ); 200 + expect(screen.getByRole("link", { name: "@lazurite.stormlightlabs.org" })).toHaveAttribute( 201 + "href", 202 + `#${buildProfileRoute("lazurite.stormlightlabs.org")}`, 203 + ); 204 + expect(screen.getByRole("link", { name: "@stormlightlabs.org" })).toHaveAttribute( 205 + "href", 206 + `#${buildProfileRoute("stormlightlabs.org")}`, 207 + ); 208 + expect(screen.getByRole("link", { name: "https://github.com/sponsors/desertthunder/" })).toHaveAttribute( 209 + "href", 210 + "https://github.com/sponsors/desertthunder/", 211 + ); 176 212 }); 177 213 178 214 it("optimistically follows and unfollows from the hero while keeping badges in sync", async () => {
+47 -17
src/components/shared/PostRichText.tsx
··· 1 + import { openExternalUrlFromEvent } from "$/lib/external-url"; 1 2 import { 3 + type LegacyRichTextPart, 2 4 parsePostRichText, 3 5 type ResolvedRichTextFacet, 4 6 resolveRichTextFacets, 5 7 type RichTextBlock, 6 8 type RichTextInlineSegment, 7 9 type RichTextLine, 8 - splitLegacyUrls, 10 + splitLegacyRichText, 9 11 } from "$/lib/post-rich-text"; 10 12 import { buildProfileRoute } from "$/lib/profile"; 11 13 import { buildHashtagRoute } from "$/lib/search-routes"; ··· 145 147 146 148 function renderFacetNode(facet: ResolvedRichTextFacet, label: string) { 147 149 if (facet.feature.$type === "app.bsky.richtext.facet#link") { 150 + const linkUri = facet.feature.uri; 148 151 return ( 149 152 <a 150 153 class="break-all text-primary no-underline hover:underline" 151 - href={facet.feature.uri} 154 + href={linkUri} 152 155 rel="noreferrer" 153 156 target="_blank" 154 - onClick={(event) => event.stopPropagation()}> 157 + onClick={(event) => openExternalUrlFromEvent(event, linkUri, "post-rich-text-facet-link")}> 155 158 {label} 156 159 </a> 157 160 ); ··· 181 184 function LegacyText(props: { text: string; useFallback: boolean }) { 182 185 return ( 183 186 <Show when={props.useFallback} fallback={<span class="wrap-anywhere">{props.text}</span>}> 184 - <For each={splitLegacyUrls(props.text)}> 185 - {(part) => ( 186 - <Show when={part.kind === "url"} fallback={<span class="wrap-anywhere">{part.text}</span>}> 187 - <a 188 - class="break-all text-primary no-underline hover:underline" 189 - href={part.text} 190 - rel="noreferrer" 191 - target="_blank" 192 - onClick={(event) => event.stopPropagation()}> 193 - {part.text} 194 - </a> 195 - </Show> 196 - )} 197 - </For> 187 + <For each={splitLegacyRichText(props.text)}>{(part) => renderLegacyPart(part)}</For> 198 188 </Show> 199 189 ); 200 190 } 191 + 192 + function renderLegacyPart(part: LegacyRichTextPart) { 193 + switch (part.kind) { 194 + case "url": { 195 + return ( 196 + <a 197 + class="break-all text-primary no-underline hover:underline" 198 + href={part.href} 199 + rel="noreferrer" 200 + target="_blank" 201 + onClick={(event) => openExternalUrlFromEvent(event, part.href, "post-rich-text-fallback-url")}> 202 + {part.text} 203 + </a> 204 + ); 205 + } 206 + case "mention": { 207 + return ( 208 + <a 209 + class="break-all text-primary no-underline hover:underline" 210 + href={`#${buildProfileRoute(part.handle)}`} 211 + onClick={(event) => event.stopPropagation()}> 212 + {part.text} 213 + </a> 214 + ); 215 + } 216 + case "hashtag": { 217 + return ( 218 + <a 219 + class="break-all text-primary no-underline hover:underline" 220 + href={`#${buildHashtagRoute(part.tag)}`} 221 + onClick={(event) => event.stopPropagation()}> 222 + {part.text} 223 + </a> 224 + ); 225 + } 226 + default: { 227 + return <span class="wrap-anywhere">{part.text}</span>; 228 + } 229 + } 230 + }
+26
src/components/shared/tests/PostRichText.test.tsx
··· 1 + import { buildProfileRoute } from "$/lib/profile"; 1 2 import { buildHashtagRoute } from "$/lib/search-routes"; 2 3 import { render, screen } from "@solidjs/testing-library"; 3 4 import { describe, expect, it } from "vitest"; ··· 40 41 expect(screen.getByText("quoted line")).toBeInTheDocument(); 41 42 expect(screen.getByText("ts")).toBeInTheDocument(); 42 43 expect(screen.getByText("const url = 'https://example.com';")).toBeInTheDocument(); 44 + }); 45 + 46 + it("linkifies legacy bios with atproto handles, hashtags, and URLs", () => { 47 + render(() => ( 48 + <PostRichText 49 + text={"A sincere engineer from #Austin building\n\n@flipper.social\n@lazurite.stormlightlabs.org\n@stormlightlabs.org\n\nhttps://github.com/sponsors/desertthunder/"} /> 50 + )); 51 + 52 + expect(screen.getByRole("link", { name: "#Austin" })).toHaveAttribute("href", `#${buildHashtagRoute("Austin")}`); 53 + expect(screen.getByRole("link", { name: "@flipper.social" })).toHaveAttribute( 54 + "href", 55 + `#${buildProfileRoute("flipper.social")}`, 56 + ); 57 + expect(screen.getByRole("link", { name: "@lazurite.stormlightlabs.org" })).toHaveAttribute( 58 + "href", 59 + `#${buildProfileRoute("lazurite.stormlightlabs.org")}`, 60 + ); 61 + expect(screen.getByRole("link", { name: "@stormlightlabs.org" })).toHaveAttribute( 62 + "href", 63 + `#${buildProfileRoute("stormlightlabs.org")}`, 64 + ); 65 + expect(screen.getByRole("link", { name: "https://github.com/sponsors/desertthunder/" })).toHaveAttribute( 66 + "href", 67 + "https://github.com/sponsors/desertthunder/", 68 + ); 43 69 }); 44 70 });
+19
src/lib/external-url.ts
··· 1 + import { normalizeError } from "$/lib/utils/text"; 2 + import * as logger from "@tauri-apps/plugin-log"; 3 + import { openUrl } from "@tauri-apps/plugin-opener"; 4 + 5 + export function openExternalUrlFromEvent(event: MouseEvent, uri: string | null | undefined, context: string) { 6 + event.stopPropagation(); 7 + event.preventDefault(); 8 + 9 + const normalizedUri = uri?.trim(); 10 + if (!normalizedUri) { 11 + return; 12 + } 13 + 14 + void openUrl(normalizedUri).catch((error) => { 15 + logger.warn("failed to open external URL", { 16 + keyValues: { context, error: normalizeError(error), uri: normalizedUri }, 17 + }); 18 + }); 19 + }
+152 -14
src/lib/post-rich-text.ts
··· 1 + import { normalizeAndEnsureValidHandle } from "@atproto/syntax"; 2 + import * as linkify from "linkifyjs"; 3 + import "linkify-plugin-hashtag"; 1 4 import type { RichTextFacet, RichTextFacetFeature } from "./types"; 2 5 3 6 export type ResolvedRichTextFacet = { end: number; feature: RichTextFacetFeature; start: number }; ··· 12 15 language: string | null; 13 16 } | { kind: "paragraph"; lines: RichTextLine[] }; 14 17 15 - const URL_REGEX = /https?:\/\/\S+/giu; 18 + const HANDLE_CHAR_REGEX = /[a-z0-9.-]/i; 19 + const HANDLE_PREFIX_CHAR_REGEX = /[a-z0-9_.-]/i; 20 + 21 + type LegacyToken = { end: number; href: string; kind: "url"; start: number; text: string } | { 22 + end: number; 23 + handle: string; 24 + kind: "mention"; 25 + start: number; 26 + text: string; 27 + } | { end: number; kind: "hashtag"; start: number; tag: string; text: string }; 28 + 29 + export type LegacyRichTextPart = { kind: "text"; text: string } | { href: string; kind: "url"; text: string } | { 30 + handle: string; 31 + kind: "mention"; 32 + text: string; 33 + } | { kind: "hashtag"; tag: string; text: string }; 16 34 17 35 export function parsePostRichText(text: string): RichTextBlock[] { 18 36 const blocks: RichTextBlock[] = []; ··· 103 121 return resolved.toSorted((left, right) => left.start - right.start || left.end - right.end); 104 122 } 105 123 106 - export function splitLegacyUrls(text: string) { 107 - const parts: Array<{ kind: "text" | "url"; text: string }> = []; 108 - let lastIndex = 0; 124 + export function splitLegacyRichText(text: string): LegacyRichTextPart[] { 125 + const tokens = collectLegacyTokens(text); 126 + if (tokens.length === 0) { 127 + return [{ kind: "text", text }]; 128 + } 109 129 110 - for (const match of text.matchAll(URL_REGEX)) { 111 - const url = match[0]; 112 - const start = match.index ?? 0; 130 + const parts: LegacyRichTextPart[] = []; 131 + let cursor = 0; 132 + 133 + for (const token of tokens) { 134 + if (token.start < cursor) { 135 + continue; 136 + } 137 + 138 + if (token.start > cursor) { 139 + parts.push({ kind: "text", text: text.slice(cursor, token.start) }); 140 + } 113 141 114 - if (start > lastIndex) { 115 - parts.push({ kind: "text", text: text.slice(lastIndex, start) }); 142 + switch (token.kind) { 143 + case "url": { 144 + parts.push({ href: token.href, kind: "url", text: token.text }); 145 + break; 146 + } 147 + case "mention": { 148 + parts.push({ handle: token.handle, kind: "mention", text: token.text }); 149 + break; 150 + } 151 + case "hashtag": { 152 + parts.push({ kind: "hashtag", tag: token.tag, text: token.text }); 153 + break; 154 + } 116 155 } 117 156 118 - parts.push({ kind: "url", text: url }); 119 - lastIndex = start + url.length; 157 + cursor = token.end; 120 158 } 121 159 122 - if (lastIndex < text.length) { 123 - parts.push({ kind: "text", text: text.slice(lastIndex) }); 160 + if (cursor < text.length) { 161 + parts.push({ kind: "text", text: text.slice(cursor) }); 124 162 } 125 163 126 - return parts.length > 0 ? parts : [{ kind: "text" as const, text }]; 164 + return parts.length > 0 ? parts : [{ kind: "text", text }]; 127 165 } 128 166 129 167 function buildUtf8BoundaryMap(text: string) { ··· 206 244 207 245 segments.push({ end, kind: "text", start }); 208 246 } 247 + 248 + function collectLegacyTokens(text: string): LegacyToken[] { 249 + const linkifyTokens = collectLinkifyTokens(text); 250 + const mentionTokens = collectMentionTokens(text); 251 + const tokens = [...linkifyTokens, ...mentionTokens]; 252 + 253 + return tokens.toSorted((left, right) => left.start - right.start || right.end - left.end); 254 + } 255 + 256 + function collectLinkifyTokens(text: string): LegacyToken[] { 257 + const tokens: LegacyToken[] = []; 258 + // linkify is not an array -> this is a false positive 259 + // eslint-disable-next-line unicorn/no-array-callback-reference 260 + const matches = linkify.find(text); 261 + 262 + for (const match of matches) { 263 + const start = match.start; 264 + const end = match.end; 265 + if (typeof start !== "number" || typeof end !== "number" || start >= end) { 266 + continue; 267 + } 268 + 269 + if (match.type === "url") { 270 + tokens.push({ end, href: match.href, kind: "url", start, text: match.value }); 271 + continue; 272 + } 273 + 274 + if (match.type === "hashtag") { 275 + const tag = match.value.replace(/^#/, "").trim(); 276 + if (!tag) { 277 + continue; 278 + } 279 + tokens.push({ end, kind: "hashtag", start, tag, text: match.value }); 280 + } 281 + } 282 + 283 + return tokens; 284 + } 285 + 286 + function collectMentionTokens(text: string): LegacyToken[] { 287 + const mentions: LegacyToken[] = []; 288 + let cursor = 0; 289 + 290 + while (cursor < text.length) { 291 + const atIndex = text.indexOf("@", cursor); 292 + if (atIndex === -1) { 293 + break; 294 + } 295 + 296 + if (atIndex > 0 && HANDLE_PREFIX_CHAR_REGEX.test(text[atIndex - 1] ?? "")) { 297 + cursor = atIndex + 1; 298 + continue; 299 + } 300 + 301 + let end = atIndex + 1; 302 + while (end < text.length && HANDLE_CHAR_REGEX.test(text[end] ?? "")) { 303 + end += 1; 304 + } 305 + 306 + const rawCandidate = text.slice(atIndex + 1, end); 307 + const normalized = normalizeMentionCandidate(rawCandidate); 308 + if (!normalized) { 309 + cursor = atIndex + 1; 310 + continue; 311 + } 312 + 313 + const normalizedEnd = atIndex + 1 + normalized.length; 314 + mentions.push({ 315 + end: normalizedEnd, 316 + handle: normalized, 317 + kind: "mention", 318 + start: atIndex, 319 + text: text.slice(atIndex, normalizedEnd), 320 + }); 321 + cursor = normalizedEnd; 322 + } 323 + 324 + return mentions; 325 + } 326 + 327 + function normalizeMentionCandidate(rawCandidate: string): string | null { 328 + if (!rawCandidate) { 329 + return null; 330 + } 331 + 332 + let candidate = rawCandidate; 333 + while (candidate.endsWith(".") || candidate.endsWith("-")) { 334 + candidate = candidate.slice(0, -1); 335 + } 336 + 337 + if (!candidate) { 338 + return null; 339 + } 340 + 341 + try { 342 + return normalizeAndEnsureValidHandle(candidate); 343 + } catch { 344 + return null; 345 + } 346 + }
+7
src/lib/profile.ts
··· 9 9 ProfileUnavailableReason, 10 10 ProfileViewBasic, 11 11 ProfileViewDetailed, 12 + RichTextFacet, 12 13 } from "$/lib/types"; 13 14 import { asArray, asRecord, optionalNumber, optionalString } from "./type-guards"; 14 15 ··· 52 53 banner: optionalString(record.banner), 53 54 createdAt: optionalString(record.createdAt), 54 55 description: optionalString(record.description), 56 + descriptionFacets: parseRichTextFacets(record.descriptionFacets), 55 57 did: record.did, 56 58 displayName: optionalString(record.displayName), 57 59 followersCount: optionalNumber(record.followersCount), ··· 67 69 viewer: parseProfileViewer(record.viewer), 68 70 website: optionalString(record.website), 69 71 }; 72 + } 73 + 74 + function parseRichTextFacets(value: unknown): RichTextFacet[] | null { 75 + const facets = asArray(value); 76 + return (facets as RichTextFacet[] | null) || null; 70 77 } 71 78 72 79 export function parseProfileResult(value: unknown): ProfileLookupResult {
+1
src/lib/tests/profile.test.ts
··· 79 79 banner: null, 80 80 createdAt: null, 81 81 description: null, 82 + descriptionFacets: null, 82 83 did: "did:plc:bob", 83 84 displayName: "Bob", 84 85 followersCount: null,
+1
src/lib/types.ts
··· 121 121 banner?: string | null; 122 122 createdAt?: string | null; 123 123 description?: string | null; 124 + descriptionFacets?: RichTextFacet[] | null; 124 125 followersCount?: number | null; 125 126 followsCount?: number | null; 126 127 indexedAt?: string | null;