diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -1,3 +1,4 @@ +import * as ATPAPI from "@atproto/api" import { useNavigate } from "@tanstack/react-router"; import DOMPurify from "dompurify"; import { useAtom } from "jotai"; @@ -44,6 +45,9 @@ lightboxCallback?: (d: LightboxProps) => void; maxReplies?: number; isQuote?: boolean; + filterNoReplies?: boolean; + filterMustHaveMedia?: boolean; + filterMustBeReply?: boolean; } // export async function cachedGetRecord({ @@ -156,6 +160,9 @@ lightboxCallback, maxReplies, isQuote, + filterNoReplies, + filterMustHaveMedia, + filterMustBeReply }: UniversalPostRendererATURILoaderProps) { // todo remove this once tree rendering is implemented, use a prop like isTree const TEMPLINEAR = true; @@ -541,6 +548,9 @@ lightboxCallback={lightboxCallback} maxReplies={maxReplies} isQuote={isQuote} + filterNoReplies={filterNoReplies} + filterMustHaveMedia={filterMustHaveMedia} + filterMustBeReply={filterMustBeReply} /> <> {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( @@ -643,6 +653,9 @@ lightboxCallback, maxReplies, isQuote, + filterNoReplies, + filterMustHaveMedia, + filterMustBeReply, }: { postRecord: any; profileRecord: any; @@ -665,6 +678,9 @@ lightboxCallback?: (d: LightboxProps) => void; maxReplies?: number; isQuote?: boolean; + filterNoReplies?: boolean; + filterMustHaveMedia?: boolean; + filterMustBeReply?: boolean; }) { // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); const navigate = useNavigate(); @@ -734,6 +750,15 @@ // run(); // }, [postRecord, resolved?.did]); + + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; + const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.images"; + const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.video"; + + const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); const { data: hydratedEmbed, @@ -829,7 +854,7 @@ // }, [fakepost, get, set]); const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent ?.uri; - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; + const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined; const replyhookvalue = useQueryIdentity( feedviewpost ? feedviewpostreplydid : undefined ); @@ -840,11 +865,20 @@ repostedby ? aturirepostbydid : undefined ); const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; + + if (filterNoReplies && thereply) return null; + + if (filterMustHaveMedia && !hasMedia) return null; + + if (filterMustBeReply && !thereply) return null; + return ( <> {/*

{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}

*/} + {/* filtermustbereply is {filterMustBeReply ? "true" : "false"} + thereply is {thereply ? "true" : "false"} */} diff --git a/src/routes/notifications.tsx b/src/routes/notifications.tsx --- a/src/routes/notifications.tsx +++ b/src/routes/notifications.tsx @@ -308,7 +308,7 @@ ); } -function Chip({ +export function Chip({ state, text, onClick, diff --git a/src/utils/atoms.ts b/src/utils/atoms.ts --- a/src/utils/atoms.ts +++ b/src/utils/atoms.ts @@ -2,6 +2,8 @@ import { atomWithStorage } from "jotai/utils"; import { useEffect } from "react"; +import { type ProfilePostsFilter } from "~/routes/profile.$did"; + export const store = createStore(); export const quickAuthAtom = atomWithStorage( @@ -69,6 +71,8 @@ "internal-liked-posts", {} ); + +export const profileChipsAtom = atom>({}) export const defaultconstellationURL = "constellation.microcosm.blue"; export const constellationURLAtom = atomWithStorage( diff --git a/src/routes/profile.$did/index.tsx b/src/routes/profile.$did/index.tsx --- a/src/routes/profile.$did/index.tsx +++ b/src/routes/profile.$did/index.tsx @@ -16,7 +16,7 @@ UniversalPostRendererATURILoader, } from "~/components/UniversalPostRenderer"; import { useAuth } from "~/providers/UnifiedAuthProvider"; -import { imgCDNAtom } from "~/utils/atoms"; +import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; import { toggleFollow, useGetFollowState, @@ -31,6 +31,8 @@ useQueryIdentity, useQueryProfile, } from "~/utils/useQuery"; + +import { Chip } from "../notifications"; export const Route = createFileRoute("/profile/$did/")({ component: ProfileComponent, @@ -207,7 +209,65 @@ ); } +export type ProfilePostsFilter = { + posts: boolean, + replies: boolean, + mediaOnly: boolean, +} +export const defaultProfilePostsFilter: ProfilePostsFilter = { + posts: true, + replies: true, + mediaOnly: false, +} + +function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { + const empty = (!filters?.replies && !filters?.posts); + const almostEmpty = (!filters?.replies && filters?.posts); + + useEffect(() => { + if (empty) { + toggle("posts") + } + }, [empty, toggle]); + + return ( +
+ almostEmpty ? null : toggle("posts")} + /> + toggle("replies")} + /> + toggle("mediaOnly")} + /> +
+ ); +} + function PostsTab({ did }: { did: string }) { + // todo: this needs to be a (non-persisted is fine) atom to survive navigation + const [filterses, setFilterses] = useAtom(profileChipsAtom); + const filters = filterses?.[did]; + const setFilters = (obj: ProfilePostsFilter) => { + setFilterses((prev)=>{ + return{ + ...prev, + [did]: obj + } + }) + } + useEffect(()=>{ + if (!filters) { + setFilters(defaultProfilePostsFilter); + } + }) useReusableTabScrollRestore(`Profile` + did); const queryClient = useQueryClient(); const { @@ -243,17 +303,35 @@ [postsData] ); + const toggle = (key: keyof ProfilePostsFilter) => { + setFilterses(prev => { + const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default + + return { + ...prev, + [did]: { + ...existing, + [key]: !existing[key], // safely negate + }, + }; + }); + }; + return ( <> -
+ {/*
Posts -
+
*/} +
{posts.map((post) => ( ))}