tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
profile feed filters
rimar1337
4 months ago
48a6f09a
74d406fb
+121
-5
4 changed files
expand all
collapse all
unified
split
src
components
UniversalPostRenderer.tsx
routes
notifications.tsx
profile.$did
index.tsx
utils
atoms.ts
+35
-1
src/components/UniversalPostRenderer.tsx
···
1
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
48
+
filterNoReplies?: boolean;
49
49
+
filterMustHaveMedia?: boolean;
50
50
+
filterMustBeReply?: boolean;
47
51
}
48
52
49
53
// export async function cachedGetRecord({
···
156
160
lightboxCallback,
157
161
maxReplies,
158
162
isQuote,
163
163
+
filterNoReplies,
164
164
+
filterMustHaveMedia,
165
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
551
+
filterNoReplies={filterNoReplies}
552
552
+
filterMustHaveMedia={filterMustHaveMedia}
553
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
656
+
filterNoReplies,
657
657
+
filterMustHaveMedia,
658
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
681
+
filterNoReplies?: boolean;
682
682
+
filterMustHaveMedia?: boolean;
683
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
754
+
const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
755
755
+
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
756
756
+
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
757
757
+
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
758
758
+
const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.images";
759
759
+
const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.video";
760
760
+
761
761
+
const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
762
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
832
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
857
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
868
+
869
869
+
if (filterNoReplies && thereply) return null;
870
870
+
871
871
+
if (filterMustHaveMedia && !hasMedia) return null;
872
872
+
873
873
+
if (filterMustBeReply && !thereply) return null;
874
874
+
843
875
return (
844
876
<>
845
877
{/* <p>
846
878
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
847
879
</p> */}
880
880
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
881
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
311
-
function Chip({
311
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
19
-
import { imgCDNAtom } from "~/utils/atoms";
19
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
34
+
35
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
212
+
export type ProfilePostsFilter = {
213
213
+
posts: boolean,
214
214
+
replies: boolean,
215
215
+
mediaOnly: boolean,
216
216
+
}
217
217
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
218
218
+
posts: true,
219
219
+
replies: true,
220
220
+
mediaOnly: false,
221
221
+
}
222
222
+
223
223
+
function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) {
224
224
+
const empty = (!filters?.replies && !filters?.posts);
225
225
+
const almostEmpty = (!filters?.replies && filters?.posts);
226
226
+
227
227
+
useEffect(() => {
228
228
+
if (empty) {
229
229
+
toggle("posts")
230
230
+
}
231
231
+
}, [empty, toggle]);
232
232
+
233
233
+
return (
234
234
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
235
235
+
<Chip
236
236
+
state={filters?.posts ?? true}
237
237
+
text="Posts"
238
238
+
onClick={() => almostEmpty ? null : toggle("posts")}
239
239
+
/>
240
240
+
<Chip
241
241
+
state={filters?.replies ?? true}
242
242
+
text="Replies"
243
243
+
onClick={() => toggle("replies")}
244
244
+
/>
245
245
+
<Chip
246
246
+
state={filters?.mediaOnly ?? false}
247
247
+
text="Media Only"
248
248
+
onClick={() => toggle("mediaOnly")}
249
249
+
/>
250
250
+
</div>
251
251
+
);
252
252
+
}
253
253
+
210
254
function PostsTab({ did }: { did: string }) {
255
255
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
256
256
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
257
257
+
const filters = filterses?.[did];
258
258
+
const setFilters = (obj: ProfilePostsFilter) => {
259
259
+
setFilterses((prev)=>{
260
260
+
return{
261
261
+
...prev,
262
262
+
[did]: obj
263
263
+
}
264
264
+
})
265
265
+
}
266
266
+
useEffect(()=>{
267
267
+
if (!filters) {
268
268
+
setFilters(defaultProfilePostsFilter);
269
269
+
}
270
270
+
})
211
271
useReusableTabScrollRestore(`Profile` + did);
212
272
const queryClient = useQueryClient();
213
273
const {
···
243
303
[postsData]
244
304
);
245
305
306
306
+
const toggle = (key: keyof ProfilePostsFilter) => {
307
307
+
setFilterses(prev => {
308
308
+
const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default
309
309
+
310
310
+
return {
311
311
+
...prev,
312
312
+
[did]: {
313
313
+
...existing,
314
314
+
[key]: !existing[key], // safely negate
315
315
+
},
316
316
+
};
317
317
+
});
318
318
+
};
319
319
+
246
320
return (
247
321
<>
248
248
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
322
322
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
249
323
Posts
250
250
-
</div>
324
324
+
</div> */}
325
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
332
+
filterNoReplies={!filters?.replies}
333
333
+
filterMustHaveMedia={filters?.mediaOnly}
334
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
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
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
74
+
75
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>(