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.

fix: author handle formatting and fallback

+271 -171
+1 -1
src/components/explorer/ExplorerPanel.test.tsx
··· 54 54 listenMock.mockReset(); 55 55 56 56 exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); 57 - clearLexiconFaviconCacheMock.mockResolvedValue(undefined); 57 + clearLexiconFaviconCacheMock.mockResolvedValue(void 0); 58 58 getLexiconFaviconsMock.mockResolvedValue({}); 59 59 getProfileMock.mockResolvedValue({ 60 60 status: "available",
+1 -1
src/components/feeds/FeedComposer.tsx
··· 141 141 return "mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center p-6 max-[640px]:p-4"; 142 142 } 143 143 144 - return "relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"; 144 + return "relative z-10 flex min-h-screen items-center justify-center p-4 pt-16"; 145 145 } 146 146 147 147 function getComposerPanelClass(layout: ComposerSurfaceProps["layout"]) {
+20
src/components/feeds/PostCard.test.tsx
··· 95 95 expect(screen.getByText("Replying to @bob.test")).toBeInTheDocument(); 96 96 }); 97 97 98 + it("falls back to did in reply context when parent handle is missing", () => { 99 + render(() => ( 100 + <PostCard 101 + item={{ 102 + post: createPost(), 103 + reply: { 104 + parent: { 105 + $type: "app.bsky.feed.defs#postView", 106 + ...createPost(), 107 + author: { did: "did:plc:bob", handle: undefined as unknown as string }, 108 + }, 109 + root: { $type: "app.bsky.feed.defs#postView", ...createPost() }, 110 + }, 111 + }} 112 + post={createPost()} /> 113 + )); 114 + 115 + expect(screen.getByText("Replying to did:plc:bob")).toBeInTheDocument(); 116 + }); 117 + 98 118 it("renders recordWithMedia embeds as media plus quoted record", () => { 99 119 render(() => ( 100 120 <PostCard
+7 -6
src/components/feeds/PostCard.tsx
··· 17 17 } from "$/lib/feeds"; 18 18 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 19 19 import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; 20 - import { formatCount } from "$/lib/utils/text"; 20 + import { formatCount, formatHandle } from "$/lib/utils/text"; 21 21 import { createMemo, createSignal, For, Match, type ParentProps, Show, Switch } from "solid-js"; 22 22 import { Motion } from "solid-motionone"; 23 23 ··· 51 51 const postText = createMemo(() => getPostText(props.post)); 52 52 const replyCount = createMemo(() => formatCount(props.post.replyCount)); 53 53 const repostCount = createMemo(() => formatCount(props.post.repostCount)); 54 + const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 54 55 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 55 56 const reasonLabel = createMemo(() => { 56 57 const reason = props.item?.reason; ··· 68 69 69 70 const parent = item.reply?.parent; 70 71 if (parent?.$type === "app.bsky.feed.defs#postView") { 71 - return `Replying to @${parent.author.handle.replace(/^@/, "")}`; 72 + return `Replying to ${formatHandle(parent.author.handle, parent.author.did)}`; 72 73 } 73 74 74 75 return "Reply in thread"; ··· 179 180 <PostPrimaryRegion onFocus={props.onFocus} onOpenThread={props.onOpenThread}> 180 181 <PostHeader 181 182 authorName={authorName()} 183 + authorHandle={authorHandle()} 182 184 createdAt={createdAt()} 183 - profileHref={profileHref()} 184 - post={props.post} /> 185 + profileHref={profileHref()} /> 185 186 186 187 <PostBodyText facets={getPostFacets(props.post)} text={postText()} /> 187 188 ··· 227 228 ); 228 229 } 229 230 230 - function PostHeader(props: { authorName: string; createdAt: string; post: PostView; profileHref: string }) { 231 + function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string; profileHref: string }) { 231 232 return ( 232 233 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 233 234 <a ··· 240 241 class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 241 242 href={`#${props.profileHref}`} 242 243 onClick={(event) => event.stopPropagation()}> 243 - @{props.post.author.handle.replace(/^@/, "")} 244 + {props.authorHandle} 244 245 </a> 245 246 <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 246 247 </header>
+203 -154
src/components/search/SearchPreflightPanel.tsx
··· 1 + /* eslint react/jsx-max-depth: ["error", { "max": 5 }] */ 1 2 import { Icon } from "$/components/shared/Icon"; 2 3 import { useAppPreferences } from "$/contexts/app-preferences"; 3 4 import { normalizeSearchReturnRoute } from "$/lib/search-routes"; ··· 9 10 10 11 const ESTIMATED_MODEL_SIZE_BYTES = 1024 * 1024 * 384; 11 12 12 - function SearchCapabilityCard( 13 - props: { body: string; icon: "search" | "explore" | "download"; title: string; tone?: "default" | "primary" }, 14 - ) { 13 + type SearchCapabilityCardProps = { 14 + body: string; 15 + icon: "search" | "explore" | "download"; 16 + title: string; 17 + tone?: "default" | "primary"; 18 + }; 19 + 20 + function SearchCapabilityCard(props: SearchCapabilityCardProps) { 15 21 return ( 16 22 <div 17 23 class="grid gap-3 rounded-3xl p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" ··· 32 38 ); 33 39 } 34 40 41 + function SearchPreflightHeader(props: { handleDismiss: () => void }) { 42 + return ( 43 + <header class="grid gap-4 px-6 pb-4 pt-6"> 44 + <div class="grid gap-2"> 45 + <p class="overline-copy text-xs text-on-surface-variant">Optional Setup</p> 46 + <div class="flex items-start justify-between gap-4"> 47 + <div class="grid gap-2"> 48 + <h1 class="m-0 text-2xl font-semibold tracking-tight text-on-surface">Semantic search is optional</h1> 49 + <p class="m-0 max-w-2xl text-sm leading-relaxed text-on-surface-variant"> 50 + Keyword and network search work right away. If you want concept matching across your synced likes and 51 + bookmarks, you can opt into local embeddings. They stay off by default. 52 + </p> 53 + </div> 54 + 55 + <button 56 + type="button" 57 + onClick={() => void props.handleDismiss()} 58 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 59 + title="Skip semantic search setup"> 60 + <Icon kind="close" class="text-lg" /> 61 + </button> 62 + </div> 63 + </div> 64 + </header> 65 + ); 66 + } 67 + 68 + function SearchPreflightDescription(props: { label: string }) { 69 + return ( 70 + <section class="grid gap-4 lg:grid-cols-[1.25fr_0.95fr]"> 71 + <div class="grid gap-4 rounded-4xl bg-black/30 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 72 + <div class="flex items-center justify-between gap-4"> 73 + <div class="grid gap-1"> 74 + <p class="m-0 text-sm font-medium text-on-surface">What changes if you enable it</p> 75 + <p class="m-0 text-xs text-on-surface-variant"> 76 + Downloads {props.label} of local model files and unlocks semantic + hybrid modes. 77 + </p> 78 + </div> 79 + <span class="rounded-full bg-primary/12 px-3 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 80 + Off by default 81 + </span> 82 + </div> 83 + 84 + <div class="grid gap-3 md:grid-cols-2"> 85 + <SearchCapabilityCard 86 + icon="search" 87 + title="Keyword search stays available" 88 + body="Search exact words across the posts you already synced. No model download required." /> 89 + <SearchCapabilityCard 90 + icon="explore" 91 + title="Semantic search becomes available" 92 + body="Find related ideas even when a post does not use the exact phrase you typed." 93 + tone="primary" /> 94 + </div> 95 + </div> 96 + 97 + <div class="grid gap-4 rounded-4xl bg-white/[0.035] p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 98 + <div class="grid gap-2"> 99 + <p class="m-0 text-sm font-medium text-on-surface">What happens next</p> 100 + <div class="grid gap-2 text-sm text-on-surface-variant"> 101 + <p class="m-0">1. Turn on semantic search.</p> 102 + <p class="m-0">2. Download the local model immediately.</p> 103 + <p class="m-0">3. Use semantic or hybrid mode from Search once setup completes.</p> 104 + </div> 105 + </div> 106 + 107 + <div class="grid gap-2 rounded-2xl bg-black/30 p-4 text-xs text-on-surface-variant"> 108 + <p class="m-0 flex items-center gap-2"> 109 + <Icon kind="db" class="text-primary" /> 110 + Existing synced posts stay local and can still be searched by keyword. 111 + </p> 112 + <p class="m-0 flex items-center gap-2"> 113 + <Icon kind="download" class="text-primary" /> 114 + If the model is already cached, re-enabling reuses it instead of downloading again. 115 + </p> 116 + </div> 117 + </div> 118 + </section> 119 + ); 120 + } 121 + 122 + type SearchPreflightDownloadProps = { 123 + progress: [number, number] | null; 124 + lastError?: string | null; 125 + downloadProgress?: number | null; 126 + downloadFile?: string | null; 127 + downloadEtaSeconds?: number | null; 128 + }; 129 + 130 + function SearchPreflightDownload(props: SearchPreflightDownloadProps) { 131 + return ( 132 + <Motion.section 133 + class="grid gap-3 rounded-4xl bg-primary/8 p-5 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]" 134 + initial={{ opacity: 0, height: 0 }} 135 + animate={{ opacity: 1, height: "auto" }} 136 + exit={{ opacity: 0, height: 0 }} 137 + transition={{ duration: 0.22 }}> 138 + <div class="flex items-center justify-between gap-3"> 139 + <div class="flex items-center gap-3"> 140 + <span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/14 text-primary"> 141 + <Show when={props.lastError} fallback={<Icon kind="download" class="text-lg" />}> 142 + <Icon kind="danger" class="text-lg" /> 143 + </Show> 144 + </span> 145 + <div class="grid gap-1"> 146 + <p class="m-0 text-sm font-medium text-on-surface"> 147 + <Show when={props.lastError} fallback={"Preparing semantic search"}>Download needs attention</Show> 148 + </p> 149 + <p class="m-0 text-xs text-on-surface-variant"> 150 + <Show 151 + when={props.lastError} 152 + fallback={"Model files are downloading in the background so semantic search can turn on."}> 153 + The download stopped before semantic search became available. 154 + </Show> 155 + </p> 156 + </div> 157 + </div> 158 + <span class="text-xs text-on-surface-variant">{formatProgress(props.downloadProgress)}</span> 159 + </div> 160 + 161 + <div class="h-2 overflow-hidden rounded-full bg-black/30"> 162 + <Motion.div 163 + class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim" 164 + animate={{ width: `${Math.max(props.downloadProgress ?? 2, 2)}%` }} 165 + transition={{ duration: 0.25 }} /> 166 + </div> 167 + 168 + <div class="grid gap-1 text-xs text-on-surface-variant"> 169 + <Show when={props.downloadFile}> 170 + {(filename) => <p class="m-0">Current file: {filename().split("/").at(-1) ?? filename()}</p>} 171 + </Show> 172 + <Show when={props.progress}> 173 + {(value) => { 174 + const [index, total] = value(); 175 + return <p class="m-0">File {index} of {total}</p>; 176 + }} 177 + </Show> 178 + <Show when={props.downloadEtaSeconds}> 179 + {(seconds) => <p class="m-0">ETA: {formatEtaSeconds(seconds())}</p>} 180 + </Show> 181 + <Show when={props.lastError}>{(message) => <p class="m-0 text-red-200">{message()}</p>}</Show> 182 + </div> 183 + </Motion.section> 184 + ); 185 + } 186 + 187 + function SearchPreflightFooter(props: { handleDismiss: () => void; enable: () => void; activating: boolean }) { 188 + return ( 189 + <section class="flex flex-wrap items-center justify-between gap-3 rounded-4xl bg-white/2.5 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 190 + <div class="grid gap-1"> 191 + <p class="m-0 text-sm font-medium text-on-surface">You can change this later from Search or Settings.</p> 192 + <p class="m-0 text-xs text-on-surface-variant"> 193 + Continue with regular search now, or opt in and let the download finish before returning. 194 + </p> 195 + </div> 196 + <div class="flex flex-wrap items-center gap-2"> 197 + <button 198 + type="button" 199 + onClick={() => void props.handleDismiss()} 200 + disabled={props.activating} 201 + class="inline-flex items-center gap-2 rounded-full border-0 bg-white/7 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"> 202 + <Icon kind="close" class="text-sm" /> 203 + <span>Continue without semantic search</span> 204 + </button> 205 + <button 206 + type="button" 207 + onClick={() => void props.enable()} 208 + disabled={props.activating} 209 + class="inline-flex items-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:cursor-not-allowed disabled:opacity-50"> 210 + <Icon 211 + kind={props.activating ? "loader" : "download"} 212 + iconClass={props.activating ? "i-ri-loader-4-line animate-spin" : undefined} 213 + class="text-sm" /> 214 + <Show when={props.activating} fallback={<span>Enable semantic search</span>}> 215 + <span>Downloading model...</span> 216 + </Show> 217 + </button> 218 + </div> 219 + </section> 220 + ); 221 + } 222 + 35 223 export function SearchPreflightPanel() { 36 224 const preferences = useAppPreferences(); 37 225 const location = useLocation(); ··· 97 285 98 286 return ( 99 287 <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 100 - <header class="grid gap-4 px-6 pb-4 pt-6"> 101 - <div class="grid gap-2"> 102 - <p class="overline-copy text-xs text-on-surface-variant">Optional Setup</p> 103 - <div class="flex items-start justify-between gap-4"> 104 - <div class="grid gap-2"> 105 - <h1 class="m-0 text-2xl font-semibold tracking-tight text-on-surface">Semantic search is optional</h1> 106 - <p class="m-0 max-w-2xl text-sm leading-relaxed text-on-surface-variant"> 107 - Keyword and network search work right away. If you want concept matching across your synced likes and 108 - bookmarks, you can opt into local embeddings. They stay off by default. 109 - </p> 110 - </div> 111 - 112 - <button 113 - type="button" 114 - onClick={() => void dismissPreflight()} 115 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 116 - title="Skip semantic search setup"> 117 - <Icon kind="close" class="text-lg" /> 118 - </button> 119 - </div> 120 - </div> 121 - </header> 122 - 288 + <SearchPreflightHeader handleDismiss={dismissPreflight} /> 123 289 <div class="min-h-0 overflow-y-auto px-6 pb-6"> 124 290 <Motion.div 125 291 class="mx-auto grid max-w-4xl gap-6" 126 292 initial={{ opacity: 0, y: 20 }} 127 293 animate={{ opacity: 1, y: 0 }} 128 294 transition={{ duration: 0.28 }}> 129 - <section class="grid gap-4 lg:grid-cols-[1.25fr_0.95fr]"> 130 - <div class="grid gap-4 rounded-[2rem] bg-black/30 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 131 - <div class="flex items-center justify-between gap-4"> 132 - <div class="grid gap-1"> 133 - <p class="m-0 text-sm font-medium text-on-surface">What changes if you enable it</p> 134 - <p class="m-0 text-xs text-on-surface-variant"> 135 - Downloads {modelSizeLabel()} of local model files and unlocks semantic + hybrid modes. 136 - </p> 137 - </div> 138 - <span class="rounded-full bg-primary/12 px-3 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 139 - Off by default 140 - </span> 141 - </div> 142 - 143 - <div class="grid gap-3 md:grid-cols-2"> 144 - <SearchCapabilityCard 145 - icon="search" 146 - title="Keyword search stays available" 147 - body="Search exact words across the posts you already synced. No model download required." /> 148 - <SearchCapabilityCard 149 - icon="explore" 150 - title="Semantic search becomes available" 151 - body="Find related ideas even when a post does not use the exact phrase you typed." 152 - tone="primary" /> 153 - </div> 154 - </div> 155 - 156 - <div class="grid gap-4 rounded-[2rem] bg-white/[0.035] p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 157 - <div class="grid gap-2"> 158 - <p class="m-0 text-sm font-medium text-on-surface">What happens next</p> 159 - <div class="grid gap-2 text-sm text-on-surface-variant"> 160 - <p class="m-0">1. Turn on semantic search.</p> 161 - <p class="m-0">2. Download the local model immediately.</p> 162 - <p class="m-0">3. Use semantic or hybrid mode from Search once setup completes.</p> 163 - </div> 164 - </div> 165 - 166 - <div class="grid gap-2 rounded-2xl bg-black/30 p-4 text-xs text-on-surface-variant"> 167 - <p class="m-0 flex items-center gap-2"> 168 - <Icon kind="db" class="text-primary" /> 169 - Existing synced posts stay local and can still be searched by keyword. 170 - </p> 171 - <p class="m-0 flex items-center gap-2"> 172 - <Icon kind="download" class="text-primary" /> 173 - If the model is already cached, re-enabling reuses it instead of downloading again. 174 - </p> 175 - </div> 176 - </div> 177 - </section> 178 - 295 + <SearchPreflightDescription label={modelSizeLabel()} /> 179 296 <Presence> 180 297 <Show 181 298 when={config()?.enabled 182 299 && (prepareRequested() || config()?.downloadActive || config()?.lastError || !config()?.downloaded)}> 183 - <Motion.section 184 - class="grid gap-3 rounded-[2rem] bg-primary/8 p-5 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]" 185 - initial={{ opacity: 0, height: 0 }} 186 - animate={{ opacity: 1, height: "auto" }} 187 - exit={{ opacity: 0, height: 0 }} 188 - transition={{ duration: 0.22 }}> 189 - <div class="flex items-center justify-between gap-3"> 190 - <div class="flex items-center gap-3"> 191 - <span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/14 text-primary"> 192 - <Icon kind={config()?.lastError ? "danger" : "download"} class="text-lg" /> 193 - </span> 194 - <div class="grid gap-1"> 195 - <p class="m-0 text-sm font-medium text-on-surface"> 196 - {config()?.lastError ? "Download needs attention" : "Preparing semantic search"} 197 - </p> 198 - <p class="m-0 text-xs text-on-surface-variant"> 199 - {config()?.lastError 200 - ? "The download stopped before semantic search became available." 201 - : "Model files are downloading in the background so semantic search can turn on."} 202 - </p> 203 - </div> 204 - </div> 205 - <span class="text-xs text-on-surface-variant">{formatProgress(config()?.downloadProgress)}</span> 206 - </div> 207 - 208 - <div class="h-2 overflow-hidden rounded-full bg-black/30"> 209 - <Motion.div 210 - class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim" 211 - animate={{ width: `${Math.max(config()?.downloadProgress ?? 2, 2)}%` }} 212 - transition={{ duration: 0.25 }} /> 213 - </div> 214 - 215 - <div class="grid gap-1 text-xs text-on-surface-variant"> 216 - <Show when={config()?.downloadFile}> 217 - {(filename) => <p class="m-0">Current file: {filename().split("/").at(-1) ?? filename()}</p>} 218 - </Show> 219 - <Show when={fileProgress()}> 220 - {(value) => { 221 - const [index, total] = value(); 222 - return <p class="m-0">File {index} of {total}</p>; 223 - }} 224 - </Show> 225 - <Show when={config()?.downloadEtaSeconds}> 226 - {(seconds) => <p class="m-0">ETA: {formatEtaSeconds(seconds())}</p>} 227 - </Show> 228 - <Show when={config()?.lastError}>{(message) => <p class="m-0 text-red-200">{message()}</p>}</Show> 229 - </div> 230 - </Motion.section> 300 + <SearchPreflightDownload 301 + progress={fileProgress()} 302 + lastError={config()?.lastError} 303 + downloadProgress={config()?.downloadProgress} 304 + downloadFile={config()?.downloadFile} 305 + downloadEtaSeconds={config()?.downloadEtaSeconds} /> 231 306 </Show> 232 307 </Presence> 233 308 234 - <section class="flex flex-wrap items-center justify-between gap-3 rounded-[2rem] bg-white/[0.025] p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 235 - <div class="grid gap-1"> 236 - <p class="m-0 text-sm font-medium text-on-surface">You can change this later from Search or Settings.</p> 237 - <p class="m-0 text-xs text-on-surface-variant"> 238 - Continue with regular search now, or opt in and let the download finish before returning. 239 - </p> 240 - </div> 241 - 242 - <div class="flex flex-wrap items-center gap-2"> 243 - <button 244 - type="button" 245 - onClick={() => void dismissPreflight()} 246 - disabled={activating()} 247 - class="inline-flex items-center gap-2 rounded-full border-0 bg-white/7 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"> 248 - <Icon kind="close" class="text-sm" /> 249 - <span>Continue without semantic search</span> 250 - </button> 251 - <button 252 - type="button" 253 - onClick={() => void enableSemanticSearch()} 254 - disabled={activating()} 255 - class="inline-flex items-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:cursor-not-allowed disabled:opacity-50"> 256 - <Icon 257 - kind={activating() ? "loader" : "download"} 258 - iconClass={activating() ? "i-ri-loader-4-line animate-spin" : undefined} 259 - class="text-sm" /> 260 - <span>{activating() ? "Downloading model..." : "Enable semantic search"}</span> 261 - </button> 262 - </div> 263 - </section> 309 + <SearchPreflightFooter 310 + handleDismiss={dismissPreflight} 311 + enable={enableSemanticSearch} 312 + activating={activating()} /> 264 313 </Motion.div> 265 314 </div> 266 315 </article>
+1 -1
src/components/search/SyncStatusPanel.test.tsx
··· 161 161 await waitFor(() => { 162 162 expect(screen.queryByTestId("sync-activity-bar")).not.toBeInTheDocument(); 163 163 }); 164 - }, 10000); 164 + }, 10_000); 165 165 166 166 it("shows source-specific progress bars", async () => { 167 167 render(() => <SyncStatusPanel did="did:plc:test" />);
+2 -1
src/components/shared/QuotedPostPreview.tsx
··· 1 1 import { getDisplayName } from "$/lib/feeds"; 2 2 import type { ProfileViewBasic } from "$/lib/types"; 3 + import { formatHandle } from "$/lib/utils/text"; 3 4 import { createMemo, Show } from "solid-js"; 4 5 5 6 export function QuotedPostPreview( ··· 34 35 <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface"> 35 36 {getDisplayName(author())} 36 37 <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 37 - @{author().handle.replace(/^@/, "")} 38 + {formatHandle(author().handle, author().did)} 38 39 </span> 39 40 </p> 40 41 )}
+9
src/lib/feeds.test.ts
··· 148 148 expect(buildPublicPostUrl(createFeedItem().post)).toBe("https://bsky.app/profile/alice.test/post/1"); 149 149 }); 150 150 151 + it("falls back to did-based post urls when handle is missing", () => { 152 + const postWithoutHandle = { 153 + ...createFeedItem().post, 154 + author: { did: "did:plc:alice", handle: undefined as unknown as string }, 155 + }; 156 + 157 + expect(buildPublicPostUrl(postWithoutHandle)).toBe("https://bsky.app/profile/did%3Aplc%3Aalice/post/1"); 158 + }); 159 + 151 160 it("rejects malformed feed payloads", () => { 152 161 expect(() => parseFeedResponse({ cursor: null, feed: {} })).toThrow("feed response payload is invalid"); 153 162 expect(() => parseFeedResponse({ cursor: 42, feed: [] })).toThrow("feed response cursor is invalid");
+27 -7
src/lib/feeds.ts
··· 240 240 export function extractHandles(posts: PostView[], activeHandle: string | null) { 241 241 const handles = new Set<string>(); 242 242 for (const post of posts) { 243 - if (post.author.handle) { 244 - handles.add(`@${post.author.handle.replace(/^@/, "")}`); 243 + const handle = normalizeHandle(post.author.handle); 244 + if (handle) { 245 + handles.add(`@${handle}`); 245 246 } 246 247 } 247 248 248 - if (activeHandle) { 249 - handles.add(`@${activeHandle.replace(/^@/, "")}`); 249 + const normalizedActiveHandle = normalizeHandle(activeHandle); 250 + if (normalizedActiveHandle) { 251 + handles.add(`@${normalizedActiveHandle}`); 250 252 } 251 253 252 254 return [...handles].toSorted((left, right) => left.localeCompare(right)); ··· 383 385 return null; 384 386 } 385 387 386 - const handle = author.handle.replace(/^@/, "").trim(); 388 + const actor = normalizeHandle(author.handle) ?? normalizeDid(author.did); 387 389 const segments = uri.split("/"); 388 390 const rkey = segments.at(-1)?.trim(); 389 391 390 - if (handle && rkey) { 391 - return `https://bsky.app/profile/${encodeURIComponent(handle)}/post/${encodeURIComponent(rkey)}`; 392 + if (actor && rkey) { 393 + return `https://bsky.app/profile/${encodeURIComponent(actor)}/post/${encodeURIComponent(rkey)}`; 392 394 } 393 395 394 396 return null; 395 397 } 398 + 399 + function normalizeHandle(value: string | null | undefined) { 400 + if (typeof value !== "string") { 401 + return null; 402 + } 403 + 404 + const normalized = value.replace(/^@/, "").trim(); 405 + return normalized || null; 406 + } 407 + 408 + function normalizeDid(value: string | null | undefined) { 409 + if (typeof value !== "string") { 410 + return null; 411 + } 412 + 413 + const normalized = value.trim(); 414 + return normalized || null; 415 + }