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

Configure Feed

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

at main 351 lines 13 kB view raw
1import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 4import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 5import { Icon } from "$/components/shared/Icon"; 6import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 7import type { DiagnosticBacklinkGroup, DiagnosticBacklinkItem } from "$/lib/api/diagnostics"; 8import { DiagnosticsController } from "$/lib/api/diagnostics"; 9import { collectModerationLabels } from "$/lib/moderation"; 10import { 11 buildPostEngagementTabRoute, 12 parsePostEngagementTab, 13 type PostEngagementTab, 14} from "$/lib/post-engagement-routes"; 15import { buildProfileRoute } from "$/lib/profile"; 16import { asRecord } from "$/lib/type-guards"; 17import type { ProfileViewBasic } from "$/lib/types"; 18import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 19import { useLocation, useNavigate } from "@solidjs/router"; 20import * as logger from "@tauri-apps/plugin-log"; 21import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 22import { createStore } from "solid-js/store"; 23 24type EngagementState = { 25 error: string | null; 26 groups: Record<PostEngagementTab, DiagnosticBacklinkGroup>; 27 loading: boolean; 28 uri: string | null; 29}; 30 31const EMPTY_GROUP: DiagnosticBacklinkGroup = { cursor: null, records: [], total: 0 }; 32const TABS: Array<{ key: PostEngagementTab; label: string }> = [{ key: "likes", label: "Likes" }, { 33 key: "reposts", 34 label: "Reposts", 35}, { key: "quotes", label: "Quotes" }]; 36 37function createInitialState(): EngagementState { 38 return { 39 error: null, 40 groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP }, 41 loading: false, 42 uri: null, 43 }; 44} 45 46export function PostEngagementPanel(props: { uri: string | null }) { 47 const location = useLocation(); 48 const navigate = useNavigate(); 49 const postNavigation = usePostNavigation(); 50 const [state, setState] = createStore<EngagementState>(createInitialState()); 51 let requestId = 0; 52 53 const activeUri = createMemo(() => props.uri?.trim() || null); 54 const activeTab = createMemo(() => parsePostEngagementTab(location.search)); 55 const activeGroup = createMemo(() => state.groups[activeTab()]); 56 const activeTabLabel = createMemo(() => TABS.find((tab) => tab.key === activeTab())?.label ?? "Likes"); 57 58 createEffect(() => { 59 const uri = activeUri(); 60 if (!uri) { 61 setState(createInitialState()); 62 return; 63 } 64 65 const nextRequestId = ++requestId; 66 setState({ 67 error: null, 68 groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP }, 69 loading: true, 70 uri, 71 }); 72 void loadEngagement(nextRequestId, uri); 73 }); 74 75 async function loadEngagement(nextRequestId: number, uri: string) { 76 try { 77 const response = await DiagnosticsController.getRecordBacklinks(uri); 78 if (nextRequestId !== requestId || uri !== activeUri()) { 79 return; 80 } 81 82 setState({ 83 error: null, 84 groups: { 85 likes: response.likes ?? EMPTY_GROUP, 86 quotes: response.quotes ?? EMPTY_GROUP, 87 reposts: response.reposts ?? EMPTY_GROUP, 88 }, 89 loading: false, 90 uri, 91 }); 92 } catch (error) { 93 const message = normalizeError(error); 94 if (nextRequestId !== requestId || uri !== activeUri()) { 95 return; 96 } 97 98 setState({ error: message, loading: false, uri }); 99 logger.error("failed to load post engagement", { keyValues: { error: message, uri } }); 100 } 101 } 102 103 function selectTab(tab: PostEngagementTab) { 104 if (tab === activeTab()) { 105 return; 106 } 107 108 void navigate(buildPostEngagementTabRoute(location.pathname, location.search, tab)); 109 } 110 111 function openProfile(item: DiagnosticBacklinkItem) { 112 const actor = item.profile?.handle?.trim() || item.did?.trim(); 113 if (!actor) { 114 return; 115 } 116 117 void navigate(buildProfileRoute(actor)); 118 } 119 120 function openQuote(item: DiagnosticBacklinkItem) { 121 if (!item.uri) { 122 return; 123 } 124 125 void postNavigation.openPostScreen(item.uri); 126 } 127 128 return ( 129 <section class="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)"> 130 <header class="sticky top-0 z-20 flex items-center justify-between gap-3 bg-surface-container-high px-6 pb-4 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_var(--outline-subtle)] max-[760px]:px-4 max-[520px]:px-3"> 131 <div class="min-w-0"> 132 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post Engagement</p> 133 <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{activeTabLabel()}</p> 134 </div> 135 <button 136 type="button" 137 class="ui-control ui-control-hoverable inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm text-on-surface" 138 onClick={() => void postNavigation.backFromPost()}> 139 <Icon aria-hidden iconClass="i-ri-arrow-left-line" /> 140 Back 141 </button> 142 </header> 143 144 <nav class="flex flex-wrap gap-2 px-3 pb-3 pt-3 max-[520px]:px-2" aria-label="Engagement tabs"> 145 <For each={TABS}> 146 {(tab) => ( 147 <button 148 type="button" 149 class="rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150 ease-out" 150 classList={{ 151 "tone-muted text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": activeTab() === tab.key, 152 "text-on-surface-variant hover:bg-surface-bright hover:text-on-surface": activeTab() !== tab.key, 153 }} 154 onClick={() => selectTab(tab.key)}> 155 {tab.label} ({activeCount(state.groups[tab.key])}) 156 </button> 157 )} 158 </For> 159 </nav> 160 161 <div class="min-h-0 overflow-y-auto px-3 pb-4 max-[520px]:px-2"> 162 <Switch> 163 <Match when={!activeUri()}> 164 <PanelMessage title="Post unavailable" body="This engagement route is missing a valid post URI." /> 165 </Match> 166 <Match when={state.loading}> 167 <EngagementSkeleton /> 168 </Match> 169 <Match when={state.error}> 170 <PanelMessage title="Couldn’t load engagement" body={state.error ?? "Try refreshing this view."} /> 171 </Match> 172 <Match when={(activeGroup().records ?? []).length === 0}> 173 <PanelMessage 174 title={`No ${activeTabLabel().toLowerCase()} yet`} 175 body={`This post does not have visible ${activeTabLabel().toLowerCase()} right now.`} /> 176 </Match> 177 <Match when={true}> 178 <EngagementList 179 items={activeGroup().records} 180 kind={activeTab()} 181 onOpenProfile={openProfile} 182 onOpenQuote={openQuote} /> 183 </Match> 184 </Switch> 185 </div> 186 </section> 187 ); 188} 189 190function activeCount(group: DiagnosticBacklinkGroup) { 191 return group.total ?? group.records.length; 192} 193 194function EngagementList( 195 props: { 196 items: DiagnosticBacklinkItem[]; 197 kind: PostEngagementTab; 198 onOpenProfile: (item: DiagnosticBacklinkItem) => void; 199 onOpenQuote: (item: DiagnosticBacklinkItem) => void; 200 }, 201) { 202 return ( 203 <div class="grid gap-3"> 204 <For each={props.items}> 205 {(item) => ( 206 <EngagementRow 207 item={item} 208 kind={props.kind} 209 onOpenProfile={props.onOpenProfile} 210 onOpenQuote={props.onOpenQuote} /> 211 )} 212 </For> 213 </div> 214 ); 215} 216 217function EngagementRow( 218 props: { 219 item: DiagnosticBacklinkItem; 220 kind: PostEngagementTab; 221 onOpenProfile: (item: DiagnosticBacklinkItem) => void; 222 onOpenQuote: (item: DiagnosticBacklinkItem) => void; 223 }, 224) { 225 const actorLabel = createMemo(() => 226 props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown account" 227 ); 228 const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); 229 const quoteInteractive = createMemo(() => props.kind === "quotes" && !!props.item.uri); 230 const profileInteractive = createMemo(() => 231 props.kind !== "quotes" && !!(props.item.profile?.handle || props.item.did) 232 ); 233 const interactive = createMemo(() => quoteInteractive() || profileInteractive()); 234 const quoteText = createMemo(() => getQuoteText(props.item)); 235 const quoteAuthor = createMemo(() => getQuoteAuthor(props.item)); 236 const profileLabels = () => collectModerationLabels(props.item.profile); 237 const avatarDecision = useModerationDecision(profileLabels, "avatar"); 238 const profileDecision = useModerationDecision(profileLabels, "profileList"); 239 240 return ( 241 <button 242 type="button" 243 class="tone-muted flex w-full items-start gap-3 rounded-3xl border-0 p-4 text-left shadow-(--inset-shadow) transition duration-150 hover:bg-surface-bright disabled:cursor-default disabled:hover:bg-panel-muted" 244 disabled={!interactive()} 245 onClick={() => { 246 if (quoteInteractive()) { 247 props.onOpenQuote(props.item); 248 return; 249 } 250 251 props.onOpenProfile(props.item); 252 }}> 253 <ModeratedAvatar 254 avatar={props.item.profile?.avatar} 255 class="ui-input-strong h-11 w-11 shrink-0 overflow-hidden rounded-full" 256 hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 257 label={initials(actorLabel())} 258 fallbackClass="text-xs font-semibold text-on-surface-variant" /> 259 <div class="min-w-0 flex-1"> 260 <div class="flex flex-wrap items-center gap-2"> 261 <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p> 262 <Show when={props.item.collection}> 263 {(collection) => ( 264 <span class="tone-muted rounded-full px-2.5 py-1 text-xs text-on-surface-variant shadow-(--inset-shadow)"> 265 {collection()} 266 </span> 267 )} 268 </Show> 269 </div> 270 <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 271 <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} /> 272 <Show 273 when={props.kind === "quotes"} 274 fallback={ 275 <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 276 }> 277 <div class="mt-2"> 278 <QuotedPostPreview 279 author={quoteAuthor()} 280 class="ui-input-strong rounded-2xl p-3 shadow-(--inset-shadow)" 281 text={quoteText() ?? ""} 282 title="Quoted post" 283 truncate /> 284 </div> 285 </Show> 286 </div> 287 <Show when={interactive()}> 288 <div class="pt-1 text-on-surface-variant"> 289 <Icon iconClass="i-ri-arrow-right-up-line" /> 290 </div> 291 </Show> 292 </button> 293 ); 294} 295 296function getQuoteRecord(item: DiagnosticBacklinkItem) { 297 return asRecord(item.value); 298} 299 300function getQuoteText(item: DiagnosticBacklinkItem) { 301 const text = getQuoteRecord(item)?.text; 302 return typeof text === "string" && text.trim().length > 0 ? text : null; 303} 304 305function getQuoteAuthor(item: DiagnosticBacklinkItem): ProfileViewBasic | null { 306 const did = item.profile?.did?.trim() || item.did?.trim(); 307 const handle = item.profile?.handle?.trim() || did; 308 if (!did || !handle) { 309 return null; 310 } 311 312 return { 313 did, 314 handle, 315 avatar: item.profile?.avatar ?? null, 316 displayName: item.profile?.displayName ?? null, 317 labels: item.profile?.labels ?? null, 318 }; 319} 320 321function PanelMessage(props: { body: string; title: string }) { 322 return ( 323 <div class="grid min-h-112 place-items-center px-6 py-10"> 324 <div class="grid max-w-lg gap-3 text-center"> 325 <p class="m-0 text-base font-medium text-on-surface">{props.title}</p> 326 <p class="m-0 text-sm text-on-surface-variant">{props.body}</p> 327 </div> 328 </div> 329 ); 330} 331 332function EngagementSkeleton() { 333 return ( 334 <div class="grid gap-3"> 335 <For each={Array.from({ length: 4 })}> 336 {() => ( 337 <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 338 <div class="flex gap-3"> 339 <div class="skeleton-block h-11 w-11 rounded-full" /> 340 <div class="grid min-w-0 flex-1 gap-2"> 341 <div class="skeleton-block h-4 w-32 rounded-full" /> 342 <div class="skeleton-block h-3 w-24 rounded-full" /> 343 <div class="skeleton-block h-3 w-full rounded-full" /> 344 </div> 345 </div> 346 </div> 347 )} 348 </For> 349 </div> 350 ); 351}