an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

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

post interactions

rimar1337 61ce2144 de4321b1

+625 -157
+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,