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: backlink handling and add quoted post support

+275 -72
+85 -20
src-tauri/src/diagnostics.rs
··· 1 - use crate::actors::{ 2 - actor_unavailable_message, classify_actor_unavailability, ActorAvailability, ActorAvailabilityReason, 3 - }; 1 + use crate::actors::{actor_unavailable_message, classify_actor_unavailability}; 2 + use crate::actors::{ActorAvailability, ActorAvailabilityReason}; 4 3 use crate::constellation::{BacklinksResponse, ConstellationClient, ConstellationLinkRecord}; 5 4 use crate::error::{AppError, Result}; 6 5 use crate::explorer; ··· 24 23 use std::collections::{BTreeMap, BTreeSet, HashMap}; 25 24 use tauri_plugin_log::log; 26 25 27 - const LIST_MEMBERSHIP_SOURCE: &str = "app.bsky.graph.listitem:subject"; 28 26 const LIST_MEMBERSHIP_PATH_TO_OTHER: &str = "list"; 27 + const BLOCK_COLLECTION: &str = "app.bsky.graph.block"; 28 + 29 + // TODO: this should be a source enum 30 + const LIST_MEMBERSHIP_SOURCE: &str = "app.bsky.graph.listitem:subject"; 29 31 const BLOCK_SOURCE: &str = "app.bsky.graph.block:subject"; 30 32 const STARTER_PACK_SOURCE: &str = "app.bsky.graph.starterpack:listItemsSample[].subject"; 31 - const BLOCK_COLLECTION: &str = "app.bsky.graph.block"; 32 33 const LIKES_SOURCE: &str = "app.bsky.feed.like:subject.uri"; 33 34 const REPOSTS_SOURCE: &str = "app.bsky.feed.repost:subject.uri"; 34 35 const REPLIES_SOURCE: &str = "app.bsky.feed.post:reply.parent.uri"; 35 36 const QUOTES_SOURCE: &str = "app.bsky.feed.post:embed.record.uri"; 37 + 38 + // TODO: this should be a Limit enum 36 39 const PUBLIC_BATCH_LIMIT: usize = 25; 37 40 const ACCOUNT_LIST_PAGE_LIMIT: u32 = 100; 38 41 const ACCOUNT_LIST_MAX_ITEMS: usize = 200; ··· 132 135 pub collection: String, 133 136 pub rkey: String, 134 137 pub profile: Option<Value>, 138 + pub value: Option<Value>, 135 139 } 136 140 137 141 #[derive(Debug, Clone, Deserialize)] ··· 418 422 let normalized_uri = normalize_at_uri(&uri)?; 419 423 let client = constellation_client(state)?; 420 424 421 - let likes = fetch_backlink_group(&client, &normalized_uri, LIKES_SOURCE).await?; 422 - let reposts = fetch_backlink_group(&client, &normalized_uri, REPOSTS_SOURCE).await?; 423 - let replies = fetch_backlink_group(&client, &normalized_uri, REPLIES_SOURCE).await?; 424 - let quotes = fetch_backlink_group(&client, &normalized_uri, QUOTES_SOURCE).await?; 425 + let likes = fetch_backlink_group(&client, &normalized_uri, LIKES_SOURCE, false).await?; 426 + let reposts = fetch_backlink_group(&client, &normalized_uri, REPOSTS_SOURCE, false).await?; 427 + let replies = fetch_backlink_group(&client, &normalized_uri, REPLIES_SOURCE, false).await?; 428 + let quotes = fetch_backlink_group(&client, &normalized_uri, QUOTES_SOURCE, true).await?; 425 429 426 430 Ok(RecordBacklinksResult { likes, reposts, replies, quotes }) 427 431 } ··· 730 734 Ok(starter_packs) 731 735 } 732 736 733 - async fn fetch_backlink_group(client: &ConstellationClient, subject: &str, source: &str) -> Result<BacklinkGroup> { 737 + async fn fetch_backlink_group( 738 + client: &ConstellationClient, subject: &str, source: &str, include_record_value: bool, 739 + ) -> Result<BacklinkGroup> { 734 740 let response = client 735 741 .get_backlinks( 736 742 subject.to_string(), ··· 741 747 .await 742 748 .map_err(|error| AppError::diagnostics("Couldn't load record backlinks right now.", error))?; 743 749 744 - build_backlink_group(response).await 750 + build_backlink_group(response, include_record_value).await 745 751 } 746 752 747 - async fn build_backlink_group(response: BacklinksResponse) -> Result<BacklinkGroup> { 753 + async fn build_backlink_group(response: BacklinksResponse, include_record_value: bool) -> Result<BacklinkGroup> { 748 754 let dids = response 749 755 .records 750 756 .iter() 751 757 .map(|record| record.did.clone()) 752 758 .collect::<Vec<_>>(); 753 759 let profiles = fetch_profiles_lookup(&dids).await?; 760 + let values = if include_record_value { 761 + fetch_backlink_record_values(&response.records).await 762 + } else { 763 + HashMap::new() 764 + }; 754 765 755 766 let records = response 756 767 .records 757 768 .into_iter() 758 - .map(|record| BacklinkRecordItem { 759 - uri: link_record_uri(&record), 760 - profile: profiles.get(&record.did).cloned(), 761 - did: record.did, 762 - collection: record.collection, 763 - rkey: record.rkey, 769 + .map(|record| { 770 + let uri = link_record_uri(&record); 771 + BacklinkRecordItem { 772 + value: values.get(&uri).cloned(), 773 + profile: profiles.get(&record.did).cloned(), 774 + did: record.did, 775 + collection: record.collection, 776 + rkey: record.rkey, 777 + uri, 778 + } 764 779 }) 765 780 .collect(); 766 781 767 782 Ok(BacklinkGroup { total: response.total, records, cursor: response.cursor }) 768 783 } 769 784 785 + async fn fetch_backlink_record_values(records: &[ConstellationLinkRecord]) -> HashMap<String, Value> { 786 + let mut values = HashMap::with_capacity(records.len()); 787 + 788 + for record in records { 789 + let uri = link_record_uri(record); 790 + match explorer::get_record(record.did.clone(), record.collection.clone(), record.rkey.clone()).await { 791 + Ok(payload) => { 792 + if let Some(value) = extract_backlink_record_value(payload) { 793 + values.insert(uri, value); 794 + } 795 + } 796 + Err(error) => { 797 + log::warn!("failed to load backlink record payload for {uri}: {error}"); 798 + } 799 + } 800 + } 801 + 802 + values 803 + } 804 + 805 + fn extract_backlink_record_value(payload: Value) -> Option<Value> { 806 + match payload { 807 + Value::Object(mut object) => object.remove("value").or(Some(Value::Object(object))), 808 + _ => None, 809 + } 810 + } 811 + 770 812 async fn fetch_profiles_lookup(dids: &[String]) -> Result<HashMap<String, Value>> { 771 813 Ok(fetch_profiles_map(dids).await?.into_iter().collect()) 772 814 } ··· 844 886 #[cfg(test)] 845 887 mod tests { 846 888 use super::{ 847 - dedupe_preserve_order, extract_blocker_dids, extract_confirmed_blocked_by_dids, extract_created_at, 848 - extract_subject_did, should_skip_missing_resource, 889 + dedupe_preserve_order, extract_backlink_record_value, extract_blocker_dids, extract_confirmed_blocked_by_dids, 890 + extract_created_at, extract_subject_did, should_skip_missing_resource, 849 891 }; 850 892 use crate::constellation::ConstellationLinkRecord; 851 893 use jacquard::api::app_bsky::graph::{get_relationships::GetRelationshipsOutputRelationshipsItem, Relationship}; ··· 927 969 assert_eq!( 928 970 extract_confirmed_blocked_by_dids(&relationships), 929 971 vec!["did:plc:one".to_string()] 972 + ); 973 + } 974 + 975 + #[test] 976 + fn extracts_backlink_record_value_field() { 977 + let payload = json!({ 978 + "uri": "at://did:plc:alice/app.bsky.feed.post/1", 979 + "value": { "text": "quoted body" } 980 + }); 981 + 982 + assert_eq!( 983 + extract_backlink_record_value(payload), 984 + Some(json!({ "text": "quoted body" })) 985 + ); 986 + } 987 + 988 + #[test] 989 + fn keeps_object_payload_when_value_field_missing() { 990 + let payload = json!({ "text": "direct record body" }); 991 + 992 + assert_eq!( 993 + extract_backlink_record_value(payload), 994 + Some(json!({ "text": "direct record body" })) 930 995 ); 931 996 } 932 997 }
+2 -2
src/components/account/AccountSwitcher.tsx
··· 73 73 classList={{ 74 74 "z-40": shell.showSwitcher, 75 75 "w-auto": compact(), 76 - "max-[1180px]:col-start-3 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 76 + "max-[1180px]:col-start-4 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 77 77 "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !shell.narrowViewport, 78 78 }} 79 79 ref={(element) => { ··· 107 107 108 108 <Show when={shell.showSwitcher}> 109 109 <div 110 - class="absolute z-50 rounded-2xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 110 + class="absolute z-50 rounded-2xl bg-surface-container-highest p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 111 111 classList={{ 112 112 "inset-x-0 bottom-[calc(100%+0.75rem)]": !compact(), 113 113 "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": compact() && !shell.narrowViewport,
+2 -1
src/components/posts/PostEngagementPanel.test.tsx
··· 45 45 did: "did:plc:carol", 46 46 profile: { handle: "carol.test", displayName: "Carol" }, 47 47 uri: "at://did:plc:carol/app.bsky.feed.post/9", 48 + value: { text: "This is a quoted post body." }, 48 49 }], 49 50 total: 1, 50 51 }, ··· 72 73 it("opens quote posts from the quotes tab", async () => { 73 74 renderPanel(`#/post/${encodeURIComponent(POST_URI)}/engagement?tab=quotes`); 74 75 75 - expect(await screen.findByText("Carol")).toBeInTheDocument(); 76 + expect(await screen.findByText("This is a quoted post body.")).toBeInTheDocument(); 76 77 77 78 fireEvent.click(screen.getByRole("button", { name: /carol/i })); 78 79
+44 -4
src/components/posts/PostEngagementPanel.tsx
··· 1 1 import { usePostNavigation } from "$/components/posts/usePostNavigation"; 2 2 import { Icon } from "$/components/shared/Icon"; 3 + import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 3 4 import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 4 5 import { 5 6 buildPostEngagementTabRoute, ··· 7 8 type PostEngagementTab, 8 9 } from "$/lib/post-engagement-routes"; 9 10 import { buildProfileRoute } from "$/lib/profile"; 11 + import { asRecord } from "$/lib/type-guards"; 12 + import type { ProfileViewBasic } from "$/lib/types"; 10 13 import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 11 14 import { useLocation, useNavigate } from "@solidjs/router"; 12 15 import * as logger from "@tauri-apps/plugin-log"; ··· 194 197 return ( 195 198 <div class="grid gap-3"> 196 199 <For each={props.items}> 197 - {(item, index) => ( 200 + {(item) => ( 198 201 <EngagementRow 199 - index={index()} 200 202 item={item} 201 203 kind={props.kind} 202 204 onOpenProfile={props.onOpenProfile} ··· 209 211 210 212 function EngagementRow( 211 213 props: { 212 - index: number; 213 214 item: DiagnosticBacklinkItem; 214 215 kind: PostEngagementTab; 215 216 onOpenProfile: (item: DiagnosticBacklinkItem) => void; ··· 225 226 props.kind !== "quotes" && !!(props.item.profile?.handle || props.item.did) 226 227 ); 227 228 const interactive = createMemo(() => quoteInteractive() || profileInteractive()); 229 + const quoteText = createMemo(() => getQuoteText(props.item)); 230 + const quoteAuthor = createMemo(() => getQuoteAuthor(props.item)); 228 231 229 232 return ( 230 233 <button ··· 254 257 </Show> 255 258 </div> 256 259 <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 257 - <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 260 + <Show 261 + when={props.kind === "quotes"} 262 + fallback={ 263 + <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 264 + }> 265 + <div class="mt-2"> 266 + <QuotedPostPreview 267 + author={quoteAuthor()} 268 + class="rounded-2xl bg-black/28 p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" 269 + text={quoteText() ?? ""} 270 + title="Quoted post" 271 + truncate /> 272 + </div> 273 + </Show> 258 274 </div> 259 275 <Show when={interactive()}> 260 276 <div class="pt-1 text-on-surface-variant"> ··· 263 279 </Show> 264 280 </button> 265 281 ); 282 + } 283 + 284 + function getQuoteRecord(item: DiagnosticBacklinkItem) { 285 + return asRecord(item.value); 286 + } 287 + 288 + function getQuoteText(item: DiagnosticBacklinkItem) { 289 + const text = getQuoteRecord(item)?.text; 290 + return typeof text === "string" && text.trim().length > 0 ? text : null; 291 + } 292 + 293 + function getQuoteAuthor(item: DiagnosticBacklinkItem): ProfileViewBasic | null { 294 + const did = item.profile?.did?.trim() || item.did?.trim(); 295 + const handle = item.profile?.handle?.trim() || did; 296 + if (!did || !handle) { 297 + return null; 298 + } 299 + 300 + return { 301 + did, 302 + handle, 303 + avatar: item.profile?.avatar ?? null, 304 + displayName: item.profile?.displayName ?? null, 305 + }; 266 306 } 267 307 268 308 function PanelMessage(props: { body: string; title: string }) {
+140 -45
src/components/rail/AppRail.tsx
··· 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 3 import { useLocation, useNavigate } from "@solidjs/router"; 4 4 import { openUrl } from "@tauri-apps/plugin-opener"; 5 - import { createEffect, createMemo, createSignal, Show } from "solid-js"; 5 + import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; 6 6 import { AccountSwitcher } from "../account/AccountSwitcher"; 7 7 import { ArrowIcon, RailFoldIcon } from "../shared/Icon"; 8 8 import { Wordmark } from "../Wordmark"; 9 9 import { RailActionButton, RailButton } from "./AppRailButton"; 10 10 11 - function RailHeader( 12 - props: { 13 - canGoBack: boolean; 14 - canGoForward: boolean; 15 - collapsed: boolean; 16 - onGoBack: () => void; 17 - onGoForward: () => void; 18 - onToggleCollapse: () => void; 19 - }, 20 - ) { 11 + function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 21 12 return ( 22 13 <> 23 14 <div ··· 25 16 classList={{ "w-full flex-col gap-3": props.collapsed }}> 26 17 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 27 18 28 - <div> 19 + <div class="max-[1180px]:hidden"> 29 20 <button 30 21 class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface" 31 22 type="button" ··· 35 26 <RailFoldIcon kind={props.collapsed ? "open" : "close"} /> 36 27 </button> 37 28 </div> 38 - </div> 39 - <div class="flex items-center gap-2 max-[1180px]:hidden"> 40 - <button 41 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 42 - type="button" 43 - aria-label="Back" 44 - disabled={!props.canGoBack} 45 - onClick={() => props.onGoBack()}> 46 - <ArrowIcon direction="left" /> 47 - </button> 48 - 49 - <button 50 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 51 - type="button" 52 - aria-label="Forward" 53 - disabled={!props.canGoForward} 54 - onClick={() => props.onGoForward()}> 55 - <ArrowIcon direction="right" /> 56 - </button> 57 29 </div> 58 30 </> 59 31 ); ··· 118 90 return { canGoBack, canGoForward, goBack, goForward }; 119 91 } 120 92 121 - function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unreadNotifications: number }) { 93 + function OverflowMenuButton(props: { hasSession: boolean; unreadNotifications: number }) { 94 + const [open, setOpen] = createSignal(false); 95 + const [menuPos, setMenuPos] = createSignal({ top: 0, right: 0 }); 96 + const location = useLocation(); 97 + let buttonRef: HTMLButtonElement | undefined; 98 + 99 + const isOverflowActive = createMemo(() => 100 + ["/saved", "/deck", "/explorer", "/settings"].some((p) => location.pathname.startsWith(p)) 101 + ); 102 + 103 + createEffect(() => { 104 + void `${location.pathname}${location.search}`; 105 + setOpen(false); 106 + }); 107 + 108 + function onOutsideClick(e: MouseEvent) { 109 + if (buttonRef && !buttonRef.contains(e.target as Node)) { 110 + setOpen(false); 111 + } 112 + } 113 + function onResize() { 114 + setOpen(false); 115 + } 116 + 117 + onMount(() => { 118 + document.addEventListener("mousedown", onOutsideClick); 119 + window.addEventListener("resize", onResize); 120 + onCleanup(() => { 121 + document.removeEventListener("mousedown", onOutsideClick); 122 + window.removeEventListener("resize", onResize); 123 + }); 124 + }); 125 + 126 + function handleToggle() { 127 + if (!open() && buttonRef) { 128 + const rect = buttonRef.getBoundingClientRect(); 129 + setMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right }); 130 + } 131 + setOpen((v) => !v); 132 + } 133 + 134 + return ( 135 + <div> 136 + <button 137 + ref={el => (buttonRef = el)} 138 + type="button" 139 + aria-label="More navigation" 140 + aria-expanded={open()} 141 + aria-haspopup="menu" 142 + onClick={handleToggle} 143 + class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-surface-bright hover:text-on-surface" 144 + classList={{ "bg-surface-container text-primary": open() || isOverflowActive() }}> 145 + <span class="flex items-center"> 146 + <i class="i-ri-more-2-line text-[1.25rem]" /> 147 + </span> 148 + </button> 149 + <Show when={open()}> 150 + <div 151 + role="menu" 152 + style={{ position: "fixed", top: `${menuPos().top}px`, right: `${menuPos().right}px` }} 153 + class="z-50 min-w-48 rounded-xl border border-white/8 bg-surface-container p-1.5 shadow-2xl"> 154 + <Show when={props.hasSession}> 155 + <RailButton end compact={false} href="/saved" label="Saved" icon="bookmark" /> 156 + <RailButton end compact={false} href="/deck" label="Deck" icon="deck" /> 157 + <RailButton end compact={false} href="/explorer" label="AT Explorer" icon="explorer" /> 158 + <RailButton end compact={false} href="/settings" label="Settings" icon="settings" /> 159 + <hr class="my-1 border-white/8" /> 160 + <RailActionButton 161 + compact={false} 162 + icon="heart" 163 + label="Support" 164 + onClick={() => void openUrl("https://github.com/sponsors/desertthunder")} /> 165 + </Show> 166 + </div> 167 + </Show> 168 + </div> 169 + ); 170 + } 171 + 172 + function RailNavigation( 173 + props: { collapsed: boolean; hasSession: boolean; narrow: boolean; unreadNotifications: number }, 174 + ) { 122 175 return ( 123 176 <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 124 177 <Show ··· 127 180 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 128 181 <RailButton compact={props.collapsed} href="/profile" label="Profile" icon="profile" /> 129 182 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 130 - <RailButton end compact={props.collapsed} href="/saved" label="Saved" icon="bookmark" /> 183 + <Show when={!props.narrow}> 184 + <RailButton end compact={props.collapsed} href="/saved" label="Saved" icon="bookmark" /> 185 + </Show> 131 186 <RailButton 132 187 end 133 188 badge={props.unreadNotifications} ··· 136 191 label="Notifications" 137 192 icon="notifications" /> 138 193 <RailButton end compact={props.collapsed} href="/messages" label="Messages" icon="messages" /> 139 - <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 140 - <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 141 - <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 194 + <Show when={!props.narrow}> 195 + <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 196 + <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 197 + <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 198 + </Show> 199 + <Show when={props.narrow}> 200 + <OverflowMenuButton hasSession={props.hasSession} unreadNotifications={props.unreadNotifications} /> 201 + </Show> 142 202 </Show> 143 203 </div> 144 204 ); ··· 146 206 147 207 function RailSecondaryActions(props: { collapsed: boolean }) { 148 208 return ( 149 - <div class="grid gap-1 max-[1180px]:col-span-full max-[1180px]:grid-flow-col max-[1180px]:justify-start"> 209 + <div class="grid gap-1 max-[1180px]:hidden max-[1180px]:col-span-full max-[1180px]:grid-flow-col max-[1180px]:justify-start"> 150 210 <RailActionButton 151 211 compact={props.collapsed} 152 212 icon="heart" ··· 156 216 ); 157 217 } 158 218 219 + function RailHistoryControls( 220 + props: { 221 + canGoBack: boolean; 222 + canGoForward: boolean; 223 + collapsed: boolean; 224 + onGoBack: () => void; 225 + onGoForward: () => void; 226 + }, 227 + ) { 228 + return ( 229 + <div 230 + class="flex items-center gap-1 max-[1180px]:col-start-3 max-[1180px]:row-start-1" 231 + classList={{ "justify-self-center": props.collapsed }}> 232 + <button 233 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 234 + type="button" 235 + aria-label="Back" 236 + disabled={!props.canGoBack} 237 + onClick={() => props.onGoBack()}> 238 + <ArrowIcon direction="left" /> 239 + </button> 240 + 241 + <button 242 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 243 + type="button" 244 + aria-label="Forward" 245 + disabled={!props.canGoForward} 246 + onClick={() => props.onGoForward()}> 247 + <ArrowIcon direction="right" /> 248 + </button> 249 + </div> 250 + ); 251 + } 252 + 159 253 export function AppRail() { 160 254 const session = useAppSession(); 161 255 const shell = useAppShellUi(); ··· 163 257 164 258 return ( 165 259 <aside 166 - class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 260 + class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 167 261 classList={{ 168 262 "items-center px-4": shell.railCondensed && !shell.narrowViewport, 169 263 "gap-5": shell.railCondensed && !shell.narrowViewport, 170 264 }} 171 265 aria-label="Primary navigation"> 172 - <RailHeader 173 - canGoBack={history.canGoBack()} 174 - canGoForward={history.canGoForward()} 175 - collapsed={shell.railCondensed} 176 - onGoBack={history.goBack} 177 - onGoForward={history.goForward} 178 - onToggleCollapse={shell.toggleRailCollapsed} /> 266 + <RailHeader collapsed={shell.railCondensed} onToggleCollapse={shell.toggleRailCollapsed} /> 179 267 <RailNavigation 180 268 collapsed={shell.railCondensed} 181 269 hasSession={session.hasSession} 270 + narrow={shell.narrowViewport} 182 271 unreadNotifications={session.unreadNotifications} /> 183 272 <div class="mt-auto grid gap-3 max-[1180px]:contents"> 184 273 <RailSecondaryActions collapsed={shell.railCondensed} /> 185 274 <AccountSwitcher /> 275 + <RailHistoryControls 276 + canGoBack={history.canGoBack()} 277 + canGoForward={history.canGoForward()} 278 + collapsed={shell.railCondensed} 279 + onGoBack={history.goBack} 280 + onGoForward={history.goForward} /> 186 281 </div> 187 282 </aside> 188 283 );
+2
src/lib/api/diagnostics.ts
··· 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 4 4 type TProfile = { did?: string | null; handle?: string | null; displayName?: string | null; avatar?: string | null }; 5 + 5 6 type TAvailability = "available" | "unavailable"; 6 7 7 8 export type DiagnosticList = { ··· 69 70 rkey?: string | null; 70 71 profile?: (TProfile & { description?: string | null }) | null; 71 72 uri?: string | null; 73 + value?: Record<string, unknown> | null; 72 74 }; 73 75 74 76 export type DiagnosticBacklinkGroup = {