atmosphere explorer
0
fork

Configure Feed

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

add AT URI prefetch record preview

Juliet f0264ce5 d7e8dd78

+162 -9
+2 -7
src/components/json.tsx
··· 15 15 import { hideMedia } from "../views/settings"; 16 16 import { pds } from "./navbar"; 17 17 import { addNotification, removeNotification } from "./notification"; 18 + import RecordHoverCard from "./record-hover-card"; 18 19 import VideoPlayer from "./video-player"; 19 20 20 21 interface JSONContext { ··· 81 82 {(part) => ( 82 83 <> 83 84 {isResourceUri(part) ? 84 - <A 85 - class="text-blue-500 hover:underline active:underline dark:text-blue-400" 86 - href={`/${part}`} 87 - target={ctx.newTab ? "_blank" : "_self"} 88 - > 89 - {part} 90 - </A> 85 + <RecordHoverCard uri={part} newTab={ctx.newTab} /> 91 86 : isDid(part) ? 92 87 <A 93 88 class="text-blue-500 hover:underline active:underline dark:text-blue-400"
+158
src/components/record-hover-card.tsx
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { ActorIdentifier } from "@atcute/lexicons"; 3 + import { A } from "@solidjs/router"; 4 + import { createEffect, createSignal, onCleanup, Show } from "solid-js"; 5 + import { isTouchDevice } from "../layout"; 6 + import { getPDS } from "../utils/api"; 7 + import { JSONValue } from "./json"; 8 + 9 + interface RecordHoverCardProps { 10 + uri: string; 11 + newTab?: boolean; 12 + } 13 + 14 + const recordCache = new Map<string, { value: unknown; loading: boolean; error?: string }>(); 15 + 16 + const parseAtUri = (uri: string) => { 17 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 18 + if (!match) return null; 19 + return { repo: match[1], collection: match[2], rkey: match[3] }; 20 + }; 21 + 22 + const prefetchRecord = async (uri: string) => { 23 + if (recordCache.has(uri)) return; 24 + 25 + const parsed = parseAtUri(uri); 26 + if (!parsed) return; 27 + 28 + recordCache.set(uri, { value: null, loading: true }); 29 + 30 + try { 31 + const pds = await getPDS(parsed.repo); 32 + const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 33 + const res = await rpc.get("com.atproto.repo.getRecord", { 34 + params: { 35 + repo: parsed.repo as ActorIdentifier, 36 + collection: parsed.collection as `${string}.${string}.${string}`, 37 + rkey: parsed.rkey, 38 + }, 39 + }); 40 + 41 + if (!res.ok) { 42 + recordCache.set(uri, { value: null, loading: false, error: res.data.error }); 43 + return; 44 + } 45 + 46 + recordCache.set(uri, { value: res.data.value, loading: false }); 47 + } catch (err: any) { 48 + recordCache.set(uri, { value: null, loading: false, error: err.message || "Failed to fetch" }); 49 + } 50 + }; 51 + 52 + const RecordHoverCard = (props: RecordHoverCardProps) => { 53 + const [show, setShow] = createSignal(false); 54 + const [record, setRecord] = createSignal<{ 55 + value: unknown; 56 + loading: boolean; 57 + error?: string; 58 + } | null>(null); 59 + let hoverTimeout: ReturnType<typeof setTimeout> | undefined; 60 + let hideTimeout: ReturnType<typeof setTimeout> | undefined; 61 + 62 + const [previewHeight, setPreviewHeight] = createSignal(0); 63 + let rkeyRef!: HTMLSpanElement; 64 + let previewRef!: HTMLDivElement; 65 + 66 + createEffect(() => { 67 + if (show() && previewRef) setPreviewHeight(previewRef.offsetHeight); 68 + }); 69 + 70 + const isOverflowing = (previewHeight: number) => 71 + rkeyRef && rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 72 + 73 + const parsed = () => parseAtUri(props.uri); 74 + 75 + const handleMouseEnter = () => { 76 + clearTimeout(hideTimeout); 77 + 78 + prefetchRecord(props.uri); 79 + 80 + hoverTimeout = setTimeout(() => { 81 + const cached = recordCache.get(props.uri); 82 + setRecord(cached || { value: null, loading: true }); 83 + setShow(true); 84 + 85 + // Poll for updates while loading 86 + if (cached?.loading) { 87 + const pollInterval = setInterval(() => { 88 + const updated = recordCache.get(props.uri); 89 + if (updated && !updated.loading) { 90 + setRecord(updated); 91 + clearInterval(pollInterval); 92 + } 93 + }, 100); 94 + 95 + setTimeout(() => clearInterval(pollInterval), 10000); 96 + } 97 + }, 200); 98 + }; 99 + 100 + const handleMouseLeave = () => { 101 + clearTimeout(hoverTimeout); 102 + hideTimeout = setTimeout(() => { 103 + setShow(false); 104 + }, 150); 105 + }; 106 + 107 + onCleanup(() => { 108 + clearTimeout(hoverTimeout); 109 + clearTimeout(hideTimeout); 110 + }); 111 + 112 + return ( 113 + <span 114 + ref={rkeyRef} 115 + class="group/hover-card relative inline" 116 + onMouseEnter={handleMouseEnter} 117 + onMouseLeave={handleMouseLeave} 118 + > 119 + <A 120 + class="text-blue-500 hover:underline active:underline dark:text-blue-400" 121 + href={`/${props.uri}`} 122 + target={props.newTab ? "_blank" : "_self"} 123 + > 124 + {props.uri} 125 + </A> 126 + <Show when={show() && !isTouchDevice}> 127 + <div 128 + ref={previewRef} 129 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-50 block max-h-80 w-max max-w-sm -translate-x-1/2 overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-xs whitespace-pre-wrap shadow-md sm:max-h-112 lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`} 130 + onMouseEnter={() => clearTimeout(hideTimeout)} 131 + onMouseLeave={handleMouseLeave} 132 + > 133 + <Show when={record()?.loading}> 134 + <div class="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> 135 + <span class="iconify lucide--loader-circle animate-spin" /> 136 + Loading... 137 + </div> 138 + </Show> 139 + <Show when={record()?.error}> 140 + <div class="text-sm text-red-500 dark:text-red-400">{record()?.error}</div> 141 + </Show> 142 + <Show when={record()?.value && !record()?.loading}> 143 + <div class="font-mono text-xs whitespace-pre-wrap"> 144 + <JSONValue 145 + data={record()?.value as any} 146 + repo={parsed()?.repo || ""} 147 + truncate 148 + newTab 149 + /> 150 + </div> 151 + </Show> 152 + </div> 153 + </Show> 154 + </span> 155 + ); 156 + }; 157 + 158 + export default RecordHoverCard;
+1 -1
src/views/car/explore.tsx
··· 522 522 let previewRef!: HTMLSpanElement; 523 523 524 524 createEffect(() => { 525 - if (hover()) setPreviewHeight(previewRef.offsetHeight); 525 + if (hover() && previewRef) setPreviewHeight(previewRef.offsetHeight); 526 526 }); 527 527 528 528 const isOverflowing = (previewHeight: number) =>
+1 -1
src/views/collection.tsx
··· 49 49 let previewRef!: HTMLSpanElement; 50 50 51 51 createEffect(() => { 52 - if (hover()) setPreviewHeight(previewRef.offsetHeight); 52 + if (hover() && previewRef) setPreviewHeight(previewRef.offsetHeight); 53 53 }); 54 54 55 55 const isOverflowing = (previewHeight: number) =>