an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

post interactions

rimar1337 61ce2144 de4321b1

+1
src/auto-imports.d.ts
··· 19 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 + const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 22 23 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 23 24 }
+18 -4
src/components/UniversalPostRenderer.tsx
··· 41 41 ref?: React.Ref<HTMLDivElement>; 42 42 dataIndexPropPass?: number; 43 43 nopics?: boolean; 44 + concise?: boolean; 44 45 lightboxCallback?: (d: LightboxProps) => void; 45 46 maxReplies?: number; 46 47 isQuote?: boolean; ··· 152 153 ref, 153 154 dataIndexPropPass, 154 155 nopics, 156 + concise, 155 157 lightboxCallback, 156 158 maxReplies, 157 159 isQuote, ··· 536 538 ref={ref} 537 539 dataIndexPropPass={dataIndexPropPass} 538 540 nopics={nopics} 541 + concise={concise} 539 542 lightboxCallback={lightboxCallback} 540 543 maxReplies={maxReplies} 541 544 isQuote={isQuote} ··· 567 570 ref={ref} 568 571 dataIndexPropPass={dataIndexPropPass} 569 572 nopics={nopics} 573 + concise={concise} 570 574 lightboxCallback={lightboxCallback} 571 575 maxReplies={ 572 576 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined ··· 636 640 ref, 637 641 dataIndexPropPass, 638 642 nopics, 643 + concise, 639 644 lightboxCallback, 640 645 maxReplies, 641 646 isQuote, ··· 657 662 ref?: React.Ref<HTMLDivElement>; 658 663 dataIndexPropPass?: number; 659 664 nopics?: boolean; 665 + concise?: boolean; 660 666 lightboxCallback?: (d: LightboxProps) => void; 661 667 maxReplies?: number; 662 668 isQuote?: boolean; ··· 874 880 ref={ref} 875 881 dataIndexPropPass={dataIndexPropPass} 876 882 nopics={nopics} 883 + concise={concise} 877 884 lightboxCallback={lightboxCallback} 878 885 maxReplies={maxReplies} 879 886 isQuote={isQuote} ··· 1327 1334 ref, 1328 1335 dataIndexPropPass, 1329 1336 nopics, 1337 + concise, 1330 1338 lightboxCallback, 1331 1339 maxReplies, 1332 1340 }: { ··· 1353 1361 ref?: React.Ref<HTMLDivElement>; 1354 1362 dataIndexPropPass?: number; 1355 1363 nopics?: boolean; 1364 + concise?: boolean; 1356 1365 lightboxCallback?: (d: LightboxProps) => void; 1357 1366 maxReplies?: number; 1358 1367 }) { ··· 1759 1768 <div 1760 1769 style={{ 1761 1770 fontSize: 16, 1762 - marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1771 + marginBottom: !post.embed || concise ? 0 : 8, 1763 1772 whiteSpace: "pre-wrap", 1764 1773 textAlign: "left", 1765 1774 overflowWrap: "anywhere", 1766 1775 wordBreak: "break-word", 1767 - //color: theme.text, 1776 + ...(concise && { 1777 + display: "-webkit-box", 1778 + WebkitBoxOrient: "vertical", 1779 + WebkitLineClamp: 2, 1780 + overflow: "hidden", 1781 + }), 1768 1782 }} 1769 1783 className="text-gray-900 dark:text-gray-100" 1770 1784 > ··· 1787 1801 </> 1788 1802 )} 1789 1803 </div> 1790 - {post.embed && depth < 1 ? ( 1804 + {post.embed && depth < 1 && !concise ? ( 1791 1805 <PostEmbeds 1792 1806 embed={post.embed} 1793 1807 //moderation={moderation} ··· 1809 1823 </div> 1810 1824 </> 1811 1825 )} 1812 - <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1826 + <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}> 1813 1827 <> 1814 1828 {expanded && ( 1815 1829 <div
+66
src/routeTree.gen.ts
··· 21 21 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 22 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 23 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 24 + import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 25 + import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 26 + import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' 24 27 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 25 28 26 29 const SettingsRoute = SettingsRouteImport.update({ ··· 84 87 path: '/profile/$did/post/$rkey', 85 88 getParentRoute: () => rootRouteImport, 86 89 } as any) 90 + const ProfileDidPostRkeyRepostedByRoute = 91 + ProfileDidPostRkeyRepostedByRouteImport.update({ 92 + id: '/reposted-by', 93 + path: '/reposted-by', 94 + getParentRoute: () => ProfileDidPostRkeyRoute, 95 + } as any) 96 + const ProfileDidPostRkeyQuotesRoute = 97 + ProfileDidPostRkeyQuotesRouteImport.update({ 98 + id: '/quotes', 99 + path: '/quotes', 100 + getParentRoute: () => ProfileDidPostRkeyRoute, 101 + } as any) 102 + const ProfileDidPostRkeyLikedByRoute = 103 + ProfileDidPostRkeyLikedByRouteImport.update({ 104 + id: '/liked-by', 105 + path: '/liked-by', 106 + getParentRoute: () => ProfileDidPostRkeyRoute, 107 + } as any) 87 108 const ProfileDidPostRkeyImageIRoute = 88 109 ProfileDidPostRkeyImageIRouteImport.update({ 89 110 id: '/image/$i', ··· 102 123 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 103 124 '/profile/$did': typeof ProfileDidIndexRoute 104 125 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 126 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 127 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 128 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 105 129 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 106 130 } 107 131 export interface FileRoutesByTo { ··· 115 139 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 116 140 '/profile/$did': typeof ProfileDidIndexRoute 117 141 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 142 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 143 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 144 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 118 145 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 119 146 } 120 147 export interface FileRoutesById { ··· 131 158 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 132 159 '/profile/$did/': typeof ProfileDidIndexRoute 133 160 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 161 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 162 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 163 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 134 164 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 135 165 } 136 166 export interface FileRouteTypes { ··· 146 176 | '/route-b' 147 177 | '/profile/$did' 148 178 | '/profile/$did/post/$rkey' 179 + | '/profile/$did/post/$rkey/liked-by' 180 + | '/profile/$did/post/$rkey/quotes' 181 + | '/profile/$did/post/$rkey/reposted-by' 149 182 | '/profile/$did/post/$rkey/image/$i' 150 183 fileRoutesByTo: FileRoutesByTo 151 184 to: ··· 159 192 | '/route-b' 160 193 | '/profile/$did' 161 194 | '/profile/$did/post/$rkey' 195 + | '/profile/$did/post/$rkey/liked-by' 196 + | '/profile/$did/post/$rkey/quotes' 197 + | '/profile/$did/post/$rkey/reposted-by' 162 198 | '/profile/$did/post/$rkey/image/$i' 163 199 id: 164 200 | '__root__' ··· 174 210 | '/_pathlessLayout/_nested-layout/route-b' 175 211 | '/profile/$did/' 176 212 | '/profile/$did/post/$rkey' 213 + | '/profile/$did/post/$rkey/liked-by' 214 + | '/profile/$did/post/$rkey/quotes' 215 + | '/profile/$did/post/$rkey/reposted-by' 177 216 | '/profile/$did/post/$rkey/image/$i' 178 217 fileRoutesById: FileRoutesById 179 218 } ··· 275 314 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 276 315 parentRoute: typeof rootRouteImport 277 316 } 317 + '/profile/$did/post/$rkey/reposted-by': { 318 + id: '/profile/$did/post/$rkey/reposted-by' 319 + path: '/reposted-by' 320 + fullPath: '/profile/$did/post/$rkey/reposted-by' 321 + preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport 322 + parentRoute: typeof ProfileDidPostRkeyRoute 323 + } 324 + '/profile/$did/post/$rkey/quotes': { 325 + id: '/profile/$did/post/$rkey/quotes' 326 + path: '/quotes' 327 + fullPath: '/profile/$did/post/$rkey/quotes' 328 + preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport 329 + parentRoute: typeof ProfileDidPostRkeyRoute 330 + } 331 + '/profile/$did/post/$rkey/liked-by': { 332 + id: '/profile/$did/post/$rkey/liked-by' 333 + path: '/liked-by' 334 + fullPath: '/profile/$did/post/$rkey/liked-by' 335 + preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport 336 + parentRoute: typeof ProfileDidPostRkeyRoute 337 + } 278 338 '/profile/$did/post/$rkey/image/$i': { 279 339 id: '/profile/$did/post/$rkey/image/$i' 280 340 path: '/image/$i' ··· 316 376 ) 317 377 318 378 interface ProfileDidPostRkeyRouteChildren { 379 + ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute 380 + ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute 381 + ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute 319 382 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 320 383 } 321 384 322 385 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 386 + ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute, 387 + ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute, 388 + ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute, 323 389 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 324 390 } 325 391
+96 -56
src/routes/notifications.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 3 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 4 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 5 import { useAtom } from "jotai"; 6 6 import * as React from "react"; 7 7 ··· 47 47 export const Route = createFileRoute("/notifications")({ 48 48 component: NotificationsComponent, 49 49 }); 50 - 51 50 52 51 export default function NotificationsTabs() { 53 52 const [activeTab, setActiveTab] = React.useState("mentions"); ··· 225 224 ); 226 225 } 227 226 228 - 229 227 function PostInteractionsTab() { 230 228 const { agent } = useAuth(); 231 229 const { data: identity } = useQueryIdentity(agent?.did); ··· 274 272 ); 275 273 } 276 274 275 + const ORDER: ("like" | "repost" | "reply" | "quote")[] = [ 276 + "like", 277 + "repost", 278 + "reply", 279 + "quote", 280 + ]; 281 + 277 282 function PostInteractionsItem({ uri }: { uri: string }) { 278 283 const { data: links } = useQueryConstellation({ 279 284 method: "/links/all", 280 285 target: uri, 281 286 }); 282 287 283 - const interactions = React.useMemo(() => { 284 - const likes = 285 - links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 286 - const replies = 287 - links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 288 - const reposts = 289 - links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 290 - const quotes1 = 291 - links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 292 - const quotes2 = 293 - links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 294 - ?.records || 0; 288 + const likes = 289 + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 290 + const replies = 291 + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 292 + const reposts = 293 + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 294 + const quotes1 = 295 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 296 + const quotes2 = 297 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 298 + ?.records || 0; 299 + const quotes = quotes1 + quotes2; 295 300 296 - const totals = { 297 - likes, 298 - replies, 299 - reposts, 300 - quotes: quotes1 + quotes2, 301 - }; 302 - 303 - const list = ( 304 - [ 305 - ["reply", totals.replies], 306 - ["repost", totals.reposts], 307 - ["like", totals.likes], 308 - ["quote", totals.quotes], 309 - ] as const 310 - ).filter(([, count]) => count > 0); 311 - 312 - return { totals, list }; 313 - }, [links]); 301 + const all = likes + replies + reposts + quotes; 314 302 315 303 return ( 316 - <div className="flex flex-col border-b pb-8"> 317 - <div className="border rounded-xl mx-4 mt-4 "> 304 + <div className="flex flex-col"> 305 + <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 318 306 <UniversalPostRendererATURILoader 319 307 isQuote 320 308 key={uri} 321 309 atUri={uri} 322 - nopics 310 + nopics={true} 311 + concise={true} 323 312 /> 324 - </div> 325 - <div className="flex flex-col"> 326 - {interactions.list.map(([type, count]) => ( 327 - <InteractionsButton key={type} type={type} uri={uri} count={count} /> 328 - ))} 313 + <div className="flex flex-col divide-x"> 314 + <InteractionsButton 315 + key={likes} 316 + type={"like"} 317 + uri={uri} 318 + count={likes} 319 + /> 320 + <InteractionsButton 321 + key={reposts} 322 + type={"repost"} 323 + uri={uri} 324 + count={reposts} 325 + /> 326 + <InteractionsButton 327 + key={replies} 328 + type={"reply"} 329 + uri={uri} 330 + count={replies} 331 + /> 332 + <InteractionsButton 333 + key={quotes} 334 + type={"quote"} 335 + uri={uri} 336 + count={quotes} 337 + /> 338 + {!all && ( 339 + <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 340 + No interactions yet. 341 + </div> 342 + )} 343 + </div> 329 344 </div> 330 345 </div> 331 346 ); ··· 340 355 uri: string; 341 356 count: number; 342 357 }) { 358 + if (!count) return <></>; 359 + const aturi = new AtUri(uri); 343 360 return ( 344 - <div className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2"> 361 + <Link 362 + to={ 363 + `/profile/$did/post/$rkey` + 364 + (type === "like" 365 + ? "/liked-by" 366 + : type === "repost" 367 + ? "/reposted-by" 368 + : type === "quote" 369 + ? "/quotes" 370 + : "") 371 + } 372 + params={{ 373 + did: aturi.host, 374 + rkey: aturi.rkey, 375 + }} 376 + className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 377 + > 345 378 {type === "like" ? ( 346 379 <MdiCardsHeartOutline height={22} width={22} /> 347 380 ) : type === "repost" ? ( 348 381 <MdiRepeat height={22} width={22} /> 349 382 ) : type === "reply" ? ( 350 383 <MdiCommentOutline height={22} width={22} /> 384 + ) : type === "quote" ? ( 385 + <IconMdiMessageReplyTextOutline 386 + height={22} 387 + width={22} 388 + className=" text-gray-400" 389 + /> 351 390 ) : ( 352 391 <></> 353 392 )} 354 393 {type} 355 394 {/* bad grammar replys */} 356 395 {count > 1 ? "s" : ""} <div className="flex-1" /> {count} 357 - </div> 396 + </Link> 358 397 ); 359 398 } 360 399 361 - function NotificationItem({ notification }: { notification: string }) { 400 + export function NotificationItem({ notification }: { notification: string }) { 362 401 const aturi = new AtUri(notification); 363 402 const navigate = useNavigate(); 364 403 const { data: identity } = useQueryIdentity(aturi.host); ··· 381 420 382 421 return ( 383 422 <div 384 - className="flex items-center gap-3 p-4 cursor-pointer border-b flex-row" 423 + className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 385 424 onClick={() => 386 425 aturi && 387 426 navigate({ ··· 390 429 }) 391 430 } 392 431 > 393 - <div> 432 + {/* <div> 394 433 {aturi.collection === "app.bsky.graph.follow" ? ( 395 434 <IconMdiAccountPlus /> 435 + ) : aturi.collection === "app.bsky.feed.like" ? ( 436 + <MdiCardsHeart /> 396 437 ) : ( 397 438 <></> 398 439 )} 399 - </div> 440 + </div> */} 400 441 {profile ? ( 401 442 <img 402 443 src={avatar || defaultpfp} ··· 406 447 ) : ( 407 448 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 408 449 )} 409 - <div className="flex flex-col"> 410 - <div className="flex flex-row gap-2"> 411 - <span className="font-medium text-gray-900 dark:text-gray-100"> 450 + <div className="flex flex-col min-w-0"> 451 + <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 452 + <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 412 453 {profile?.displayName || identity?.handle || "Someone"} 413 454 </span> 414 - <span className="text-gray-700 dark:text-gray-400"> 455 + <span className="text-gray-700 dark:text-gray-400 truncate"> 415 456 @{identity?.handle} 416 457 </span> 417 458 </div> ··· 428 469 ); 429 470 } 430 471 431 - 432 - const EmptyState = ({ text }: { text: string }) => ( 472 + export const EmptyState = ({ text }: { text: string }) => ( 433 473 <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 434 474 {text} 435 475 </div> 436 476 ); 437 477 438 - const LoadingState = ({ text }: { text: string }) => ( 478 + export const LoadingState = ({ text }: { text: string }) => ( 439 479 <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 440 480 {text} 441 481 </div> 442 482 ); 443 483 444 - const ErrorState = ({ error }: { error: unknown }) => ( 484 + export const ErrorState = ({ error }: { error: unknown }) => ( 445 485 <div className="py-10 text-center text-red-600 dark:text-red-400"> 446 486 Error: {(error as Error)?.message || "Something went wrong."} 447 487 </div> 448 - ); 488 + );
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/liked-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.like", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteLikesData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const likesAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteLikesData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteLikesData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Liked By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading likes..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!likesAturis?.length) 81 + return <EmptyState text="No likes yet." />; 82 + })()} 83 + </> 84 + 85 + {likesAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 + import { constellationURLAtom } from "~/utils/atoms"; 9 + import { type linksRecord,useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 10 + 11 + import { 12 + EmptyState, 13 + ErrorState, 14 + LoadingState, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/quotes")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresultsWithoutMedia = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.post", 34 + path: ".embed.record.uri", // embed.record.record.uri and embed.record.uri 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + const infinitequeryresultsWithMedia = useInfiniteQuery({ 40 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 41 + { 42 + constellation: constellationurl, 43 + method: "/links", 44 + target: atUri, 45 + collection: "app.bsky.feed.post", 46 + path: ".embed.record.record.uri", // embed.record.record.uri and embed.record.uri 47 + } 48 + ), 49 + enabled: !!atUri, 50 + }); 51 + 52 + const { 53 + data: infiniteQuotesDataWithoutMedia, 54 + fetchNextPage: fetchNextPageWithoutMedia, 55 + hasNextPage: hasNextPageWithoutMedia, 56 + isFetchingNextPage: isFetchingNextPageWithoutMedia, 57 + isLoading: isLoadingWithoutMedia, 58 + isError: isErrorWithoutMedia, 59 + error: errorWithoutMedia, 60 + } = infinitequeryresultsWithoutMedia; 61 + const { 62 + data: infiniteQuotesDataWithMedia, 63 + fetchNextPage: fetchNextPageWithMedia, 64 + hasNextPage: hasNextPageWithMedia, 65 + isFetchingNextPage: isFetchingNextPageWithMedia, 66 + isLoading: isLoadingWithMedia, 67 + isError: isErrorWithMedia, 68 + error: errorWithMedia, 69 + } = infinitequeryresultsWithMedia; 70 + 71 + const fetchNextPage = async () => { 72 + await Promise.all([ 73 + hasNextPageWithMedia && fetchNextPageWithMedia(), 74 + hasNextPageWithoutMedia && fetchNextPageWithoutMedia(), 75 + ]); 76 + }; 77 + 78 + const hasNextPage = hasNextPageWithMedia || hasNextPageWithoutMedia; 79 + const isFetchingNextPage = isFetchingNextPageWithMedia || isFetchingNextPageWithoutMedia; 80 + const isLoading = isLoadingWithMedia || isLoadingWithoutMedia; 81 + 82 + const allQuotes = React.useMemo(() => { 83 + const withPages = infiniteQuotesDataWithMedia?.pages ?? []; 84 + const withoutPages = infiniteQuotesDataWithoutMedia?.pages ?? []; 85 + const maxLen = Math.max(withPages.length, withoutPages.length); 86 + const merged: linksRecord[] = []; 87 + 88 + for (let i = 0; i < maxLen; i++) { 89 + const a = withPages[i]?.linking_records ?? []; 90 + const b = withoutPages[i]?.linking_records ?? []; 91 + const mergedPage = [...a, ...b].sort((b, a) => a.rkey.localeCompare(b.rkey)); 92 + merged.push(...mergedPage); 93 + } 94 + 95 + return merged; 96 + }, [infiniteQuotesDataWithMedia?.pages, infiniteQuotesDataWithoutMedia?.pages]); 97 + 98 + const quotesAturis = React.useMemo(() => { 99 + return allQuotes.flatMap((r) => `at://${r.did}/${r.collection}/${r.rkey}`); 100 + }, [allQuotes]); 101 + 102 + return ( 103 + <> 104 + <Header 105 + title={`Quotes`} 106 + backButtonCallback={() => { 107 + if (window.history.length > 1) { 108 + window.history.back(); 109 + } else { 110 + window.location.assign("/"); 111 + } 112 + }} 113 + /> 114 + 115 + <> 116 + {(() => { 117 + if (isLoading) return <LoadingState text="Loading quotes..." />; 118 + if (isErrorWithMedia) return <ErrorState error={errorWithMedia} />; 119 + if (isErrorWithoutMedia) return <ErrorState error={errorWithoutMedia} />; 120 + 121 + if (!quotesAturis?.length) 122 + return <EmptyState text="No quotes yet." />; 123 + })()} 124 + </> 125 + 126 + {quotesAturis.map((m) => ( 127 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 128 + ))} 129 + 130 + {hasNextPage && ( 131 + <button 132 + onClick={() => fetchNextPage()} 133 + disabled={isFetchingNextPage} 134 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 135 + > 136 + {isFetchingNextPage ? "Loading..." : "Load More"} 137 + </button> 138 + )} 139 + </> 140 + ); 141 + }
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/reposted-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.repost", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteRepostsData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const repostsAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteRepostsData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteRepostsData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Reposted By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading reposts..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!repostsAturis?.length) 81 + return <EmptyState text="No reposts yet." />; 82 + })()} 83 + </> 84 + 85 + {repostsAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+98 -92
src/routes/profile.$did/post.$rkey.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, Outlet } from "@tanstack/react-router"; 3 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 4 4 import { useAtom } from "jotai"; 5 5 import React, { useLayoutEffect } from "react"; 6 6 ··· 52 52 nopics?: boolean; 53 53 lightboxCallback?: (d: LightboxProps) => void; 54 54 }) { 55 + const matchRoute = useMatchRoute() 56 + const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' }) 57 + 55 58 //const { get, set } = usePersistentStore(); 56 59 const queryClient = useQueryClient(); 57 60 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 190 193 data: identity, 191 194 isLoading: isIdentityLoading, 192 195 error: identityError, 193 - } = useQueryIdentity(did); 196 + } = useQueryIdentity(showMainPostRoute ? did : undefined); 194 197 195 198 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 196 199 197 200 const atUri = React.useMemo( 198 201 () => 199 - resolvedDid 202 + resolvedDid && showMainPostRoute 200 203 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 201 204 : undefined, 202 - [resolvedDid, rkey] 205 + [resolvedDid, rkey, showMainPostRoute] 203 206 ); 204 207 205 - const { data: mainPost } = useQueryPost(atUri); 208 + const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined); 206 209 207 210 console.log("atUri",atUri) 208 211 ··· 215 218 ); 216 219 217 220 // @ts-expect-error i hate overloads 218 - const { data: links } = useQueryConstellation(atUri?{ 221 + const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{ 219 222 method: "/links/all", 220 223 target: atUri, 221 224 } : { ··· 248 251 }, [links]); 249 252 250 253 const { data: opreplies } = useQueryConstellation( 251 - !!opdid && replyCount && replyCount >= 25 254 + showMainPostRoute && !!opdid && replyCount && replyCount >= 25 252 255 ? { 253 256 method: "/links", 254 257 target: atUri, ··· 289 292 path: ".reply.parent.uri", 290 293 } 291 294 ), 292 - enabled: !!atUri, 295 + enabled: !!atUri && showMainPostRoute, 293 296 }); 294 297 295 298 const { ··· 371 374 const [layoutReady, setLayoutReady] = React.useState(false); 372 375 373 376 useLayoutEffect(() => { 377 + if (!showMainPostRoute) return 374 378 if (parents.length > 0 && !layoutReady && mainPostRef.current) { 375 379 const mainPostElement = mainPostRef.current; 376 380 ··· 389 393 // eslint-disable-next-line react-hooks/set-state-in-effect 390 394 setLayoutReady(true); 391 395 } 392 - }, [parents, layoutReady]); 396 + }, [parents, layoutReady, showMainPostRoute]); 393 397 394 398 395 399 const [slingshoturl] = useAtom(slingshotURLAtom) 396 400 397 401 React.useEffect(() => { 398 - if (parentsLoading) { 402 + if (parentsLoading || !showMainPostRoute) { 399 403 setLayoutReady(false); 400 404 } 401 405 ··· 403 407 setLayoutReady(true); 404 408 hasPerformedInitialLayout.current = true; 405 409 } 406 - }, [parentsLoading, mainPost]); 410 + }, [parentsLoading, mainPost, showMainPostRoute]); 407 411 408 412 React.useEffect(() => { 409 413 if (!mainPost?.value?.reply?.parent?.uri) { ··· 444 448 return () => { 445 449 ignore = true; 446 450 }; 447 - }, [mainPost, queryClient]); 451 + }, [mainPost, queryClient, slingshoturl]); 448 452 449 - if (!did || !rkey) return <div>Invalid post URI</div>; 450 - if (isIdentityLoading) return <div>Resolving handle...</div>; 451 - if (identityError) 453 + if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>; 454 + if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>; 455 + if (identityError && showMainPostRoute) 452 456 return <div style={{ color: "red" }}>{identityError.message}</div>; 453 - if (!atUri) return <div>Could not construct post URI.</div>; 457 + if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>; 454 458 455 459 return ( 456 460 <> 457 461 <Outlet /> 458 - <Header 459 - title={`Post`} 460 - backButtonCallback={() => { 461 - if (window.history.length > 1) { 462 - window.history.back(); 463 - } else { 464 - window.location.assign("/"); 465 - } 466 - }} 467 - /> 462 + {showMainPostRoute && (<> 463 + <Header 464 + title={`Post`} 465 + backButtonCallback={() => { 466 + if (window.history.length > 1) { 467 + window.history.back(); 468 + } else { 469 + window.location.assign("/"); 470 + } 471 + }} 472 + /> 468 473 469 - {parentsLoading && ( 470 - <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 471 - <div className="ml-4 w-[42px] flex justify-center"> 472 - <div 473 - style={{ width: 2, height: "100%", opacity: 0.5 }} 474 - className="bg-gray-500 dark:bg-gray-400" 475 - ></div> 474 + {parentsLoading && ( 475 + <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 476 + <div className="ml-4 w-[42px] flex justify-center"> 477 + <div 478 + style={{ width: 2, height: "100%", opacity: 0.5 }} 479 + className="bg-gray-500 dark:bg-gray-400" 480 + ></div> 481 + </div> 482 + Loading conversation... 476 483 </div> 477 - Loading conversation... 484 + )} 485 + 486 + {/* we should use the reply lines here thats provided by UPR*/} 487 + <div style={{ maxWidth: 600, padding: 0 }}> 488 + {parents.map((parent, index) => ( 489 + <UniversalPostRendererATURILoader 490 + key={parent.uri} 491 + atUri={parent.uri} 492 + topReplyLine={index > 0} 493 + bottomReplyLine={true} 494 + bottomBorder={false} 495 + /> 496 + ))} 478 497 </div> 479 - )} 480 - 481 - {/* we should use the reply lines here thats provided by UPR*/} 482 - <div style={{ maxWidth: 600, padding: 0 }}> 483 - {parents.map((parent, index) => ( 498 + <div ref={mainPostRef}> 484 499 <UniversalPostRendererATURILoader 485 - key={parent.uri} 486 - atUri={parent.uri} 487 - topReplyLine={index > 0} 488 - bottomReplyLine={true} 489 - bottomBorder={false} 500 + atUri={atUri!} 501 + detailed={true} 502 + topReplyLine={parentsLoading || parents.length > 0} 503 + nopics={!!nopics} 504 + lightboxCallback={lightboxCallback} 490 505 /> 491 - ))} 492 - </div> 493 - <div ref={mainPostRef}> 494 - <UniversalPostRendererATURILoader 495 - atUri={atUri} 496 - detailed={true} 497 - topReplyLine={parentsLoading || parents.length > 0} 498 - nopics={!!nopics} 499 - lightboxCallback={lightboxCallback} 500 - /> 501 - </div> 502 - <div 503 - style={{ 504 - maxWidth: 600, 505 - //margin: "0px auto 0", 506 - padding: 0, 507 - minHeight: "80dvh", 508 - paddingBottom: "20dvh", 509 - }} 510 - > 506 + </div> 511 507 <div 512 - className="text-gray-500 dark:text-gray-400 text-sm font-bold" 513 508 style={{ 514 - fontSize: 18, 515 - margin: "12px 16px 12px 16px", 516 - fontWeight: 600, 509 + maxWidth: 600, 510 + //margin: "0px auto 0", 511 + padding: 0, 512 + minHeight: "80dvh", 513 + paddingBottom: "20dvh", 517 514 }} 518 515 > 519 - Replies 516 + <div 517 + className="text-gray-500 dark:text-gray-400 text-sm font-bold" 518 + style={{ 519 + fontSize: 18, 520 + margin: "12px 16px 12px 16px", 521 + fontWeight: 600, 522 + }} 523 + > 524 + Replies 525 + </div> 526 + <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 527 + {replyAturis.length > 0 && 528 + replyAturis.map((reply) => { 529 + //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 530 + return ( 531 + <UniversalPostRendererATURILoader 532 + key={reply} 533 + atUri={reply} 534 + maxReplies={4} 535 + /> 536 + ); 537 + })} 538 + {hasNextPage && ( 539 + <button 540 + onClick={() => fetchNextPage()} 541 + disabled={isFetchingNextPage} 542 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 543 + > 544 + {isFetchingNextPage ? "Loading..." : "Load More"} 545 + </button> 546 + )} 547 + </div> 520 548 </div> 521 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 522 - {replyAturis.length > 0 && 523 - replyAturis.map((reply) => { 524 - //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 525 - return ( 526 - <UniversalPostRendererATURILoader 527 - key={reply} 528 - atUri={reply} 529 - maxReplies={4} 530 - /> 531 - ); 532 - })} 533 - {hasNextPage && ( 534 - <button 535 - onClick={() => fetchNextPage()} 536 - disabled={isFetchingNextPage} 537 - className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 538 - > 539 - {isFetchingNextPage ? "Loading..." : "Load More"} 540 - </button> 541 - )} 542 - </div> 543 - </div> 549 + </>)} 544 550 </> 545 551 ); 546 552 }
+5 -5
src/utils/useQuery.ts
··· 352 352 ); 353 353 } 354 354 355 - type linksRecord = { 355 + export type linksRecord = { 356 356 did: string; 357 357 collection: string; 358 358 rkey: string; ··· 634 634 collection: string 635 635 path: string 636 636 }) { 637 - console.log( 638 - 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 - query, 640 - ) 637 + // console.log( 638 + // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 + // query, 640 + // ) 641 641 642 642 return infiniteQueryOptions({ 643 643 enabled: !!query?.target,