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

Configure Feed

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

profile feed filters

rimar1337 48a6f09a 74d406fb

+121 -5
+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>(