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