+35
-1
src/components/UniversalPostRenderer.tsx
+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
+1
-1
src/routes/notifications.tsx
+81
-3
src/routes/profile.$did/index.tsx
+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
+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>(