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 * as ATPAPI from "@atproto/api" 1 2 import { useNavigate } from "@tanstack/react-router"; 2 3 import DOMPurify from "dompurify"; 3 4 import { useAtom } from "jotai"; ··· 44 45 lightboxCallback?: (d: LightboxProps) => void; 45 46 maxReplies?: number; 46 47 isQuote?: boolean; 48 + filterNoReplies?: boolean; 49 + filterMustHaveMedia?: boolean; 50 + filterMustBeReply?: boolean; 47 51 } 48 52 49 53 // export async function cachedGetRecord({ ··· 156 160 lightboxCallback, 157 161 maxReplies, 158 162 isQuote, 163 + filterNoReplies, 164 + filterMustHaveMedia, 165 + filterMustBeReply 159 166 }: UniversalPostRendererATURILoaderProps) { 160 167 // todo remove this once tree rendering is implemented, use a prop like isTree 161 168 const TEMPLINEAR = true; ··· 541 548 lightboxCallback={lightboxCallback} 542 549 maxReplies={maxReplies} 543 550 isQuote={isQuote} 551 + filterNoReplies={filterNoReplies} 552 + filterMustHaveMedia={filterMustHaveMedia} 553 + filterMustBeReply={filterMustBeReply} 544 554 /> 545 555 <> 546 556 {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( ··· 643 653 lightboxCallback, 644 654 maxReplies, 645 655 isQuote, 656 + filterNoReplies, 657 + filterMustHaveMedia, 658 + filterMustBeReply, 646 659 }: { 647 660 postRecord: any; 648 661 profileRecord: any; ··· 665 678 lightboxCallback?: (d: LightboxProps) => void; 666 679 maxReplies?: number; 667 680 isQuote?: boolean; 681 + filterNoReplies?: boolean; 682 + filterMustHaveMedia?: boolean; 683 + filterMustBeReply?: boolean; 668 684 }) { 669 685 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 670 686 const navigate = useNavigate(); ··· 735 751 // run(); 736 752 // }, [postRecord, resolved?.did]); 737 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 + 738 763 const { 739 764 data: hydratedEmbed, 740 765 isLoading: isEmbedLoading, ··· 829 854 // }, [fakepost, get, set]); 830 855 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 831 856 ?.uri; 832 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 857 + const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined; 833 858 const replyhookvalue = useQueryIdentity( 834 859 feedviewpost ? feedviewpostreplydid : undefined 835 860 ); ··· 840 865 repostedby ? aturirepostbydid : undefined 841 866 ); 842 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 + 843 875 return ( 844 876 <> 845 877 {/* <p> 846 878 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 847 879 </p> */} 880 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 881 + <span>thereply is {thereply ? "true" : "false"}</span> */} 848 882 <UniversalPostRenderer 849 883 expanded={detailed} 850 884 onPostClick={() =>
+1 -1
src/routes/notifications.tsx
··· 308 308 ); 309 309 } 310 310 311 - function Chip({ 311 + export function Chip({ 312 312 state, 313 313 text, 314 314 onClick,
+81 -3
src/routes/profile.$did/index.tsx
··· 16 16 UniversalPostRendererATURILoader, 17 17 } from "~/components/UniversalPostRenderer"; 18 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 - import { imgCDNAtom } from "~/utils/atoms"; 19 + import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 20 import { 21 21 toggleFollow, 22 22 useGetFollowState, ··· 31 31 useQueryIdentity, 32 32 useQueryProfile, 33 33 } from "~/utils/useQuery"; 34 + 35 + import { Chip } from "../notifications"; 34 36 35 37 export const Route = createFileRoute("/profile/$did/")({ 36 38 component: ProfileComponent, ··· 207 209 ); 208 210 } 209 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 + 210 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 + }) 211 271 useReusableTabScrollRestore(`Profile` + did); 212 272 const queryClient = useQueryClient(); 213 273 const { ··· 243 303 [postsData] 244 304 ); 245 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 + 246 320 return ( 247 321 <> 248 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 322 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 249 323 Posts 250 - </div> 324 + </div> */} 325 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 251 326 <div> 252 327 {posts.map((post) => ( 253 328 <UniversalPostRendererATURILoader 254 329 key={post.uri} 255 330 atUri={post.uri} 256 331 feedviewpost={true} 332 + filterNoReplies={!filters?.replies} 333 + filterMustHaveMedia={filters?.mediaOnly} 334 + filterMustBeReply={!filters?.posts} 257 335 /> 258 336 ))} 259 337 </div>
+4
src/utils/atoms.ts
··· 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import { useEffect } from "react"; 4 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 5 7 export const store = createStore(); 6 8 7 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 69 71 "internal-liked-posts", 70 72 {} 71 73 ); 74 + 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 72 76 73 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 74 78 export const constellationURLAtom = atomWithStorage<string>(