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

profile feed filters

rimar1337 48a6f09a 74d406fb

Changed files
+121 -5
src
components
routes
utils
+35 -1
src/components/UniversalPostRenderer.tsx
··· 1 import { useNavigate } from "@tanstack/react-router"; 2 import DOMPurify from "dompurify"; 3 import { useAtom } from "jotai"; ··· 44 lightboxCallback?: (d: LightboxProps) => void; 45 maxReplies?: number; 46 isQuote?: boolean; 47 } 48 49 // export async function cachedGetRecord({ ··· 156 lightboxCallback, 157 maxReplies, 158 isQuote, 159 }: UniversalPostRendererATURILoaderProps) { 160 // todo remove this once tree rendering is implemented, use a prop like isTree 161 const TEMPLINEAR = true; ··· 541 lightboxCallback={lightboxCallback} 542 maxReplies={maxReplies} 543 isQuote={isQuote} 544 /> 545 <> 546 {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( ··· 643 lightboxCallback, 644 maxReplies, 645 isQuote, 646 }: { 647 postRecord: any; 648 profileRecord: any; ··· 665 lightboxCallback?: (d: LightboxProps) => void; 666 maxReplies?: number; 667 isQuote?: boolean; 668 }) { 669 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 670 const navigate = useNavigate(); ··· 735 // run(); 736 // }, [postRecord, resolved?.did]); 737 738 const { 739 data: hydratedEmbed, 740 isLoading: isEmbedLoading, ··· 829 // }, [fakepost, get, set]); 830 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 831 ?.uri; 832 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 833 const replyhookvalue = useQueryIdentity( 834 feedviewpost ? feedviewpostreplydid : undefined 835 ); ··· 840 repostedby ? aturirepostbydid : undefined 841 ); 842 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 843 return ( 844 <> 845 {/* <p> 846 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 847 </p> */} 848 <UniversalPostRenderer 849 expanded={detailed} 850 onPostClick={() =>
··· 1 + import * as ATPAPI from "@atproto/api" 2 import { useNavigate } from "@tanstack/react-router"; 3 import DOMPurify from "dompurify"; 4 import { useAtom } from "jotai"; ··· 45 lightboxCallback?: (d: LightboxProps) => void; 46 maxReplies?: number; 47 isQuote?: boolean; 48 + filterNoReplies?: boolean; 49 + filterMustHaveMedia?: boolean; 50 + filterMustBeReply?: boolean; 51 } 52 53 // export async function cachedGetRecord({ ··· 160 lightboxCallback, 161 maxReplies, 162 isQuote, 163 + filterNoReplies, 164 + filterMustHaveMedia, 165 + filterMustBeReply 166 }: UniversalPostRendererATURILoaderProps) { 167 // todo remove this once tree rendering is implemented, use a prop like isTree 168 const TEMPLINEAR = true; ··· 548 lightboxCallback={lightboxCallback} 549 maxReplies={maxReplies} 550 isQuote={isQuote} 551 + filterNoReplies={filterNoReplies} 552 + filterMustHaveMedia={filterMustHaveMedia} 553 + filterMustBeReply={filterMustBeReply} 554 /> 555 <> 556 {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( ··· 653 lightboxCallback, 654 maxReplies, 655 isQuote, 656 + filterNoReplies, 657 + filterMustHaveMedia, 658 + filterMustBeReply, 659 }: { 660 postRecord: any; 661 profileRecord: any; ··· 678 lightboxCallback?: (d: LightboxProps) => void; 679 maxReplies?: number; 680 isQuote?: boolean; 681 + filterNoReplies?: boolean; 682 + filterMustHaveMedia?: boolean; 683 + filterMustBeReply?: boolean; 684 }) { 685 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 686 const navigate = useNavigate(); ··· 751 // run(); 752 // }, [postRecord, resolved?.did]); 753 754 + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 755 + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 756 + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 757 + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 758 + const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.images"; 759 + const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.video"; 760 + 761 + const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 762 + 763 const { 764 data: hydratedEmbed, 765 isLoading: isEmbedLoading, ··· 854 // }, [fakepost, get, set]); 855 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 856 ?.uri; 857 + const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined; 858 const replyhookvalue = useQueryIdentity( 859 feedviewpost ? feedviewpostreplydid : undefined 860 ); ··· 865 repostedby ? aturirepostbydid : undefined 866 ); 867 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 868 + 869 + if (filterNoReplies && thereply) return null; 870 + 871 + if (filterMustHaveMedia && !hasMedia) return null; 872 + 873 + if (filterMustBeReply && !thereply) return null; 874 + 875 return ( 876 <> 877 {/* <p> 878 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 879 </p> */} 880 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 881 + <span>thereply is {thereply ? "true" : "false"}</span> */} 882 <UniversalPostRenderer 883 expanded={detailed} 884 onPostClick={() =>
+1 -1
src/routes/notifications.tsx
··· 308 ); 309 } 310 311 - function Chip({ 312 state, 313 text, 314 onClick,
··· 308 ); 309 } 310 311 + export function Chip({ 312 state, 313 text, 314 onClick,
+81 -3
src/routes/profile.$did/index.tsx
··· 16 UniversalPostRendererATURILoader, 17 } from "~/components/UniversalPostRenderer"; 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 - import { imgCDNAtom } from "~/utils/atoms"; 20 import { 21 toggleFollow, 22 useGetFollowState, ··· 31 useQueryIdentity, 32 useQueryProfile, 33 } from "~/utils/useQuery"; 34 35 export const Route = createFileRoute("/profile/$did/")({ 36 component: ProfileComponent, ··· 207 ); 208 } 209 210 function PostsTab({ did }: { did: string }) { 211 useReusableTabScrollRestore(`Profile` + did); 212 const queryClient = useQueryClient(); 213 const { ··· 243 [postsData] 244 ); 245 246 return ( 247 <> 248 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 249 Posts 250 - </div> 251 <div> 252 {posts.map((post) => ( 253 <UniversalPostRendererATURILoader 254 key={post.uri} 255 atUri={post.uri} 256 feedviewpost={true} 257 /> 258 ))} 259 </div>
··· 16 UniversalPostRendererATURILoader, 17 } from "~/components/UniversalPostRenderer"; 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 + import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 import { 21 toggleFollow, 22 useGetFollowState, ··· 31 useQueryIdentity, 32 useQueryProfile, 33 } from "~/utils/useQuery"; 34 + 35 + import { Chip } from "../notifications"; 36 37 export const Route = createFileRoute("/profile/$did/")({ 38 component: ProfileComponent, ··· 209 ); 210 } 211 212 + export type ProfilePostsFilter = { 213 + posts: boolean, 214 + replies: boolean, 215 + mediaOnly: boolean, 216 + } 217 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 218 + posts: true, 219 + replies: true, 220 + mediaOnly: false, 221 + } 222 + 223 + function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { 224 + const empty = (!filters?.replies && !filters?.posts); 225 + const almostEmpty = (!filters?.replies && filters?.posts); 226 + 227 + useEffect(() => { 228 + if (empty) { 229 + toggle("posts") 230 + } 231 + }, [empty, toggle]); 232 + 233 + return ( 234 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 235 + <Chip 236 + state={filters?.posts ?? true} 237 + text="Posts" 238 + onClick={() => almostEmpty ? null : toggle("posts")} 239 + /> 240 + <Chip 241 + state={filters?.replies ?? true} 242 + text="Replies" 243 + onClick={() => toggle("replies")} 244 + /> 245 + <Chip 246 + state={filters?.mediaOnly ?? false} 247 + text="Media Only" 248 + onClick={() => toggle("mediaOnly")} 249 + /> 250 + </div> 251 + ); 252 + } 253 + 254 function PostsTab({ did }: { did: string }) { 255 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 256 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 257 + const filters = filterses?.[did]; 258 + const setFilters = (obj: ProfilePostsFilter) => { 259 + setFilterses((prev)=>{ 260 + return{ 261 + ...prev, 262 + [did]: obj 263 + } 264 + }) 265 + } 266 + useEffect(()=>{ 267 + if (!filters) { 268 + setFilters(defaultProfilePostsFilter); 269 + } 270 + }) 271 useReusableTabScrollRestore(`Profile` + did); 272 const queryClient = useQueryClient(); 273 const { ··· 303 [postsData] 304 ); 305 306 + const toggle = (key: keyof ProfilePostsFilter) => { 307 + setFilterses(prev => { 308 + const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 309 + 310 + return { 311 + ...prev, 312 + [did]: { 313 + ...existing, 314 + [key]: !existing[key], // safely negate 315 + }, 316 + }; 317 + }); 318 + }; 319 + 320 return ( 321 <> 322 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 323 Posts 324 + </div> */} 325 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 326 <div> 327 {posts.map((post) => ( 328 <UniversalPostRendererATURILoader 329 key={post.uri} 330 atUri={post.uri} 331 feedviewpost={true} 332 + filterNoReplies={!filters?.replies} 333 + filterMustHaveMedia={filters?.mediaOnly} 334 + filterMustBeReply={!filters?.posts} 335 /> 336 ))} 337 </div>
+4
src/utils/atoms.ts
··· 2 import { atomWithStorage } from "jotai/utils"; 3 import { useEffect } from "react"; 4 5 export const store = createStore(); 6 7 export const quickAuthAtom = atomWithStorage<string | null>( ··· 69 "internal-liked-posts", 70 {} 71 ); 72 73 export const defaultconstellationURL = "constellation.microcosm.blue"; 74 export const constellationURLAtom = atomWithStorage<string>(
··· 2 import { atomWithStorage } from "jotai/utils"; 3 import { useEffect } from "react"; 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 7 export const store = createStore(); 8 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 71 "internal-liked-posts", 72 {} 73 ); 74 + 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 76 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 78 export const constellationURLAtom = atomWithStorage<string>(