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
basic notifs
rimar1337
4 months ago
de4321b1
b819cd67
+466
-154
5 changed files
expand all
collapse all
unified
split
src
auto-imports.d.ts
components
Header.tsx
UniversalPostRenderer.tsx
routes
notifications.tsx
styles
app.css
+1
src/auto-imports.d.ts
···
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
0
21
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
22
}
···
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
+
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
23
}
+4
-2
src/components/Header.tsx
···
5
6
export function Header({
7
backButtonCallback,
8
-
title
0
9
}: {
10
backButtonCallback?: () => void;
11
title?: string;
0
12
}) {
13
const router = useRouter();
14
const [isAtTop] = useAtom(isAtTopAtom);
15
//const what = router.history.
16
return (
17
-
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
18
{backButtonCallback ? (<Link
19
to=".."
20
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
···
5
6
export function Header({
7
backButtonCallback,
8
+
title,
9
+
bottomBorderDisabled,
10
}: {
11
backButtonCallback?: () => void;
12
title?: string;
13
+
bottomBorderDisabled?: boolean;
14
}) {
15
const router = useRouter();
16
const [isAtTop] = useAtom(isAtTopAtom);
17
//const what = router.history.
18
return (
19
+
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
20
{backButtonCallback ? (<Link
21
to=".."
22
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+1
src/components/UniversalPostRenderer.tsx
···
2564
// =
2565
if (AppBskyEmbedVideo.isView(embed)) {
2566
// hls playlist
0
2567
const playlist = embed.playlist;
2568
return (
2569
<SmartHLSPlayer
···
2564
// =
2565
if (AppBskyEmbedVideo.isView(embed)) {
2566
// hls playlist
2567
+
if (nopics) return;
2568
const playlist = embed.playlist;
2569
return (
2570
<SmartHLSPlayer
+424
-152
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
0
0
0
2
import { useAtom } from "jotai";
3
-
import React, { useEffect, useRef,useState } from "react";
4
0
0
0
0
0
0
0
0
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
-
import { constellationURLAtom } from "~/utils/atoms";
0
0
0
0
0
0
0
7
8
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
9
10
export const Route = createFileRoute("/notifications")({
11
component: NotificationsComponent,
12
});
13
14
-
function NotificationsComponent() {
15
-
// /*mass comment*/ console.log("NotificationsComponent render");
16
-
const { agent, status } = useAuth();
17
-
const authed = !!agent?.did;
18
-
const authLoading = status === "loading";
19
-
const [did, setDid] = useState<string | null>(null);
20
-
const [resolving, setResolving] = useState(false);
21
-
const [error, setError] = useState<string | null>(null);
22
-
const [responses, setResponses] = useState<any[]>([null, null, null]);
23
-
const [loading, setLoading] = useState(false);
24
-
const inputRef = useRef<HTMLInputElement>(null);
25
26
-
useEffect(() => {
27
-
if (authLoading) return;
28
-
if (authed && agent && agent.assertDid) {
29
-
setDid(agent.assertDid);
30
-
}
31
-
}, [authed, agent, authLoading]);
32
33
-
async function handleSubmit() {
34
-
// /*mass comment*/ console.log("handleSubmit called");
35
-
setError(null);
36
-
setResponses([null, null, null]);
37
-
const value = inputRef.current?.value?.trim() || "";
38
-
if (!value) return;
39
-
if (value.startsWith("did:")) {
40
-
setDid(value);
41
-
setError(null);
42
-
return;
43
-
}
44
-
setResolving(true);
45
-
const cacheKey = `handleDid:${value}`;
46
-
const now = Date.now();
47
-
const cached = undefined // await get(cacheKey);
48
-
// if (
49
-
// cached &&
50
-
// cached.value &&
51
-
// cached.time &&
52
-
// now - cached.time < HANDLE_DID_CACHE_TIMEOUT
53
-
// ) {
54
-
// try {
55
-
// const data = JSON.parse(cached.value);
56
-
// setDid(data.did);
57
-
// setResolving(false);
58
-
// return;
59
-
// } catch {}
60
-
// }
61
-
try {
62
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`;
63
-
const res = await fetch(url);
64
-
if (!res.ok) throw new Error("Failed to resolve handle");
65
-
const data = await res.json();
66
-
//set(cacheKey, JSON.stringify(data));
67
-
setDid(data.did);
68
-
} catch (e: any) {
69
-
setError("Failed to resolve handle: " + (e?.message || e));
70
-
} finally {
71
-
setResolving(false);
72
-
}
73
-
}
74
75
-
const [constellationURL] = useAtom(constellationURLAtom)
0
0
0
76
77
-
useEffect(() => {
78
-
if (!did) return;
79
-
setLoading(true);
80
-
setError(null);
81
-
const urls = [
82
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
85
-
];
86
-
let ignore = false;
87
-
Promise.all(
88
-
urls.map(async (url) => {
89
-
try {
90
-
const r = await fetch(url);
91
-
if (!r.ok) throw new Error("Failed to fetch");
92
-
const text = await r.text();
93
-
if (!text) return null;
94
-
try {
95
-
return JSON.parse(text);
96
-
} catch {
97
-
return null;
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
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
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
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
98
}
99
-
} catch (e: any) {
100
-
return { error: e?.message || String(e) };
101
-
}
102
-
})
103
-
)
104
-
.then((results) => {
105
-
if (!ignore) setResponses(results);
106
-
})
107
-
.catch((e) => {
108
-
if (!ignore)
109
-
setError("Failed to fetch notifications: " + (e?.message || e));
110
-
})
111
-
.finally(() => {
112
-
if (!ignore) setLoading(false);
113
});
114
-
return () => {
115
-
ignore = true;
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
0
0
0
0
0
0
0
116
};
117
-
}, [did]);
0
0
0
0
0
0
0
0
0
0
0
118
119
return (
120
-
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
121
-
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800">
122
-
<span className="text-xl font-bold ml-2">Notifications</span>
123
-
{!authed && (
124
-
<div className="flex items-center gap-2">
125
-
<input
126
-
type="text"
127
-
placeholder="Enter handle or DID"
128
-
ref={inputRef}
129
-
className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"
130
-
style={{ minWidth: 220 }}
131
-
disabled={resolving}
132
-
/>
133
-
<button
134
-
type="button"
135
-
className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50"
136
-
disabled={resolving}
137
-
onClick={handleSubmit}
138
-
>
139
-
{resolving ? "Resolving..." : "Submit"}
140
-
</button>
141
-
</div>
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
142
)}
143
</div>
144
-
{error && <div className="p-4 text-red-500">{error}</div>}
145
-
{loading && (
146
-
<div className="p-4 text-gray-500">Loading notifications...</div>
0
0
0
0
0
147
)}
148
-
{!loading &&
149
-
!error &&
150
-
responses.map((resp, i) => (
151
-
<div key={i} className="p-4">
152
-
<div className="font-bold mb-2">Query {i + 1}</div>
153
-
{!resp ||
154
-
(typeof resp === "object" && Object.keys(resp).length === 0) ||
155
-
(Array.isArray(resp) && resp.length === 0) ? (
156
-
<div className="text-gray-500">No notifications found.</div>
157
-
) : (
158
-
<pre
159
-
style={{
160
-
background: "#222",
161
-
color: "#eee",
162
-
borderRadius: 8,
163
-
padding: 12,
164
-
fontSize: 13,
165
-
overflowX: "auto",
166
-
}}
167
-
>
168
-
{JSON.stringify(resp, null, 2)}
169
-
</pre>
170
-
)}
171
-
</div>
172
-
))}
173
-
{/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */}
174
</div>
175
);
176
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { AtUri } from "@atproto/api";
2
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
3
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
5
import { useAtom } from "jotai";
6
+
import * as React from "react";
7
8
+
import defaultpfp from "~/../public/favicon.png";
9
+
import { Header } from "~/components/Header";
10
+
import {
11
+
MdiCardsHeartOutline,
12
+
MdiCommentOutline,
13
+
MdiRepeat,
14
+
UniversalPostRendererATURILoader,
15
+
} from "~/components/UniversalPostRenderer";
16
import { useAuth } from "~/providers/UnifiedAuthProvider";
17
+
import { constellationURLAtom, imgCDNAtom, isAtTopAtom } from "~/utils/atoms";
18
+
import {
19
+
useInfiniteQueryAuthorFeed,
20
+
useQueryConstellation,
21
+
useQueryIdentity,
22
+
useQueryProfile,
23
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
24
+
} from "~/utils/useQuery";
25
26
+
import { FollowButton, Mutual } from "./profile.$did";
27
+
28
+
export function NotificationsComponent() {
29
+
return (
30
+
<div className="">
31
+
<Header
32
+
title={`Notifications`}
33
+
backButtonCallback={() => {
34
+
if (window.history.length > 1) {
35
+
window.history.back();
36
+
} else {
37
+
window.location.assign("/");
38
+
}
39
+
}}
40
+
bottomBorderDisabled={true}
41
+
/>
42
+
<NotificationsTabs />
43
+
</div>
44
+
);
45
+
}
46
47
export const Route = createFileRoute("/notifications")({
48
component: NotificationsComponent,
49
});
50
0
0
0
0
0
0
0
0
0
0
0
51
52
+
export default function NotificationsTabs() {
53
+
const [activeTab, setActiveTab] = React.useState("mentions");
54
+
const [isAtTop] = useAtom(isAtTopAtom);
0
0
0
55
56
+
const scrollPositions = React.useRef<Record<string, number>>({});
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
57
58
+
const handleValueChange = (newTab: string) => {
59
+
scrollPositions.current[activeTab] = window.scrollY;
60
+
setActiveTab(newTab);
61
+
};
62
63
+
React.useEffect(() => {
64
+
const savedY = scrollPositions.current[activeTab] ?? 0;
65
+
window.scrollTo(0, savedY);
66
+
}, [activeTab]);
67
+
68
+
return (
69
+
<TabsPrimitive.Root
70
+
value={activeTab}
71
+
onValueChange={handleValueChange}
72
+
className={`w-full`}
73
+
>
74
+
<TabsPrimitive.List
75
+
className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}
76
+
>
77
+
<TabsPrimitive.Trigger
78
+
value="mentions"
79
+
// styling is in app.css
80
+
>
81
+
Mentions
82
+
</TabsPrimitive.Trigger>
83
+
<TabsPrimitive.Trigger value="follows">Follows</TabsPrimitive.Trigger>
84
+
<TabsPrimitive.Trigger value="postInteractions">
85
+
Post Interactions
86
+
</TabsPrimitive.Trigger>
87
+
</TabsPrimitive.List>
88
+
89
+
<TabsPrimitive.Content value="mentions" className="flex-1">
90
+
{activeTab === "mentions" && <MentionsTab />}
91
+
</TabsPrimitive.Content>
92
+
93
+
<TabsPrimitive.Content value="follows" className="flex-1">
94
+
{activeTab === "follows" && <FollowsTab />}
95
+
</TabsPrimitive.Content>
96
+
97
+
<TabsPrimitive.Content value="postInteractions" className="flex-1">
98
+
{activeTab === "postInteractions" && <PostInteractionsTab />}
99
+
</TabsPrimitive.Content>
100
+
</TabsPrimitive.Root>
101
+
);
102
+
}
103
+
104
+
function MentionsTab() {
105
+
const { agent } = useAuth();
106
+
const [constellationurl] = useAtom(constellationURLAtom);
107
+
const infinitequeryresults = useInfiniteQuery({
108
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
109
+
{
110
+
constellation: constellationurl,
111
+
method: "/links",
112
+
target: agent?.did,
113
+
collection: "app.bsky.feed.post",
114
+
path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did",
115
+
}
116
+
),
117
+
enabled: !!agent?.did,
118
+
});
119
+
120
+
const {
121
+
data: infiniteMentionsData,
122
+
fetchNextPage,
123
+
hasNextPage,
124
+
isFetchingNextPage,
125
+
isLoading,
126
+
isError,
127
+
error,
128
+
} = infinitequeryresults;
129
+
130
+
const mentionsAturis = React.useMemo(() => {
131
+
// Get all replies from the standard infinite query
132
+
return (
133
+
infiniteMentionsData?.pages.flatMap(
134
+
(page) =>
135
+
page?.linking_records.map(
136
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
137
+
) ?? []
138
+
) ?? []
139
+
);
140
+
}, [infiniteMentionsData]);
141
+
142
+
if (isLoading) return <LoadingState text="Loading mentions..." />;
143
+
if (isError) return <ErrorState error={error} />;
144
+
145
+
if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />;
146
+
147
+
return (
148
+
<>
149
+
{mentionsAturis.map((m) => (
150
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
151
+
))}
152
+
153
+
{hasNextPage && (
154
+
<button
155
+
onClick={() => fetchNextPage()}
156
+
disabled={isFetchingNextPage}
157
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
158
+
>
159
+
{isFetchingNextPage ? "Loading..." : "Load More"}
160
+
</button>
161
+
)}
162
+
</>
163
+
);
164
+
}
165
+
166
+
function FollowsTab() {
167
+
const { agent } = useAuth();
168
+
const [constellationurl] = useAtom(constellationURLAtom);
169
+
const infinitequeryresults = useInfiniteQuery({
170
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
171
+
{
172
+
constellation: constellationurl,
173
+
method: "/links",
174
+
target: agent?.did,
175
+
collection: "app.bsky.graph.follow",
176
+
path: ".subject",
177
+
}
178
+
),
179
+
enabled: !!agent?.did,
180
+
});
181
+
182
+
const {
183
+
data: infiniteFollowsData,
184
+
fetchNextPage,
185
+
hasNextPage,
186
+
isFetchingNextPage,
187
+
isLoading,
188
+
isError,
189
+
error,
190
+
} = infinitequeryresults;
191
+
192
+
const followsAturis = React.useMemo(() => {
193
+
// Get all replies from the standard infinite query
194
+
return (
195
+
infiniteFollowsData?.pages.flatMap(
196
+
(page) =>
197
+
page?.linking_records.map(
198
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
199
+
) ?? []
200
+
) ?? []
201
+
);
202
+
}, [infiniteFollowsData]);
203
+
204
+
if (isLoading) return <LoadingState text="Loading mentions..." />;
205
+
if (isError) return <ErrorState error={error} />;
206
+
207
+
if (!followsAturis?.length) return <EmptyState text="No mentions yet." />;
208
+
209
+
return (
210
+
<>
211
+
{followsAturis.map((m) => (
212
+
<NotificationItem key={m} notification={m} />
213
+
))}
214
+
215
+
{hasNextPage && (
216
+
<button
217
+
onClick={() => fetchNextPage()}
218
+
disabled={isFetchingNextPage}
219
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
220
+
>
221
+
{isFetchingNextPage ? "Loading..." : "Load More"}
222
+
</button>
223
+
)}
224
+
</>
225
+
);
226
+
}
227
+
228
+
229
+
function PostInteractionsTab() {
230
+
const { agent } = useAuth();
231
+
const { data: identity } = useQueryIdentity(agent?.did);
232
+
const queryClient = useQueryClient();
233
+
const {
234
+
data: postsData,
235
+
fetchNextPage,
236
+
hasNextPage,
237
+
isFetchingNextPage,
238
+
isLoading: arePostsLoading,
239
+
} = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds);
240
+
241
+
React.useEffect(() => {
242
+
if (postsData) {
243
+
postsData.pages.forEach((page) => {
244
+
page.records.forEach((record) => {
245
+
if (!queryClient.getQueryData(["post", record.uri])) {
246
+
queryClient.setQueryData(["post", record.uri], record);
247
}
248
+
});
0
0
0
0
0
0
0
0
0
0
0
0
0
249
});
250
+
}
251
+
}, [postsData, queryClient]);
252
+
253
+
const posts = React.useMemo(
254
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
255
+
[postsData]
256
+
);
257
+
258
+
return (
259
+
<>
260
+
{posts.map((m) => (
261
+
<PostInteractionsItem key={m.uri} uri={m.uri} />
262
+
))}
263
+
264
+
{hasNextPage && (
265
+
<button
266
+
onClick={() => fetchNextPage()}
267
+
disabled={isFetchingNextPage}
268
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
269
+
>
270
+
{isFetchingNextPage ? "Loading..." : "Load More"}
271
+
</button>
272
+
)}
273
+
</>
274
+
);
275
+
}
276
+
277
+
function PostInteractionsItem({ uri }: { uri: string }) {
278
+
const { data: links } = useQueryConstellation({
279
+
method: "/links/all",
280
+
target: uri,
281
+
});
282
+
283
+
const interactions = React.useMemo(() => {
284
+
const likes =
285
+
links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0;
286
+
const replies =
287
+
links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0;
288
+
const reposts =
289
+
links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0;
290
+
const quotes1 =
291
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0;
292
+
const quotes2 =
293
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"]
294
+
?.records || 0;
295
+
296
+
const totals = {
297
+
likes,
298
+
replies,
299
+
reposts,
300
+
quotes: quotes1 + quotes2,
301
};
302
+
303
+
const list = (
304
+
[
305
+
["reply", totals.replies],
306
+
["repost", totals.reposts],
307
+
["like", totals.likes],
308
+
["quote", totals.quotes],
309
+
] as const
310
+
).filter(([, count]) => count > 0);
311
+
312
+
return { totals, list };
313
+
}, [links]);
314
315
return (
316
+
<div className="flex flex-col border-b pb-8">
317
+
<div className="border rounded-xl mx-4 mt-4 ">
318
+
<UniversalPostRendererATURILoader
319
+
isQuote
320
+
key={uri}
321
+
atUri={uri}
322
+
nopics
323
+
/>
324
+
</div>
325
+
<div className="flex flex-col">
326
+
{interactions.list.map(([type, count]) => (
327
+
<InteractionsButton key={type} type={type} uri={uri} count={count} />
328
+
))}
329
+
</div>
330
+
</div>
331
+
);
332
+
}
333
+
334
+
function InteractionsButton({
335
+
type,
336
+
uri,
337
+
count,
338
+
}: {
339
+
type: "reply" | "repost" | "like" | "quote";
340
+
uri: string;
341
+
count: number;
342
+
}) {
343
+
return (
344
+
<div className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2">
345
+
{type === "like" ? (
346
+
<MdiCardsHeartOutline height={22} width={22} />
347
+
) : type === "repost" ? (
348
+
<MdiRepeat height={22} width={22} />
349
+
) : type === "reply" ? (
350
+
<MdiCommentOutline height={22} width={22} />
351
+
) : (
352
+
<></>
353
+
)}
354
+
{type}
355
+
{/* bad grammar replys */}
356
+
{count > 1 ? "s" : ""} <div className="flex-1" /> {count}
357
+
</div>
358
+
);
359
+
}
360
+
361
+
function NotificationItem({ notification }: { notification: string }) {
362
+
const aturi = new AtUri(notification);
363
+
const navigate = useNavigate();
364
+
const { data: identity } = useQueryIdentity(aturi.host);
365
+
const resolvedDid = identity?.did;
366
+
const profileUri = resolvedDid
367
+
? `at://${resolvedDid}/app.bsky.actor.profile/self`
368
+
: undefined;
369
+
const { data: profileRecord } = useQueryProfile(profileUri);
370
+
const profile = profileRecord?.value;
371
+
372
+
const [imgcdn] = useAtom(imgCDNAtom);
373
+
374
+
function getAvatarUrl(p: typeof profile) {
375
+
const link = p?.avatar?.ref?.["$link"];
376
+
if (!link || !resolvedDid) return null;
377
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
378
+
}
379
+
380
+
const avatar = getAvatarUrl(profile);
381
+
382
+
return (
383
+
<div
384
+
className="flex items-center gap-3 p-4 cursor-pointer border-b flex-row"
385
+
onClick={() =>
386
+
aturi &&
387
+
navigate({
388
+
to: "/profile/$did",
389
+
params: { did: aturi.host },
390
+
})
391
+
}
392
+
>
393
+
<div>
394
+
{aturi.collection === "app.bsky.graph.follow" ? (
395
+
<IconMdiAccountPlus />
396
+
) : (
397
+
<></>
398
)}
399
</div>
400
+
{profile ? (
401
+
<img
402
+
src={avatar || defaultpfp}
403
+
alt={identity?.handle}
404
+
className="w-10 h-10 rounded-full"
405
+
/>
406
+
) : (
407
+
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
408
)}
409
+
<div className="flex flex-col">
410
+
<div className="flex flex-row gap-2">
411
+
<span className="font-medium text-gray-900 dark:text-gray-100">
412
+
{profile?.displayName || identity?.handle || "Someone"}
413
+
</span>
414
+
<span className="text-gray-700 dark:text-gray-400">
415
+
@{identity?.handle}
416
+
</span>
417
+
</div>
418
+
<div className="flex flex-row gap-2">
419
+
{identity?.did && <Mutual targetdidorhandle={identity?.did} />}
420
+
{/* <span className="text-sm text-gray-600 dark:text-gray-400">
421
+
followed you
422
+
</span> */}
423
+
</div>
424
+
</div>
425
+
<div className="flex-1" />
426
+
{identity?.did && <FollowButton targetdidorhandle={identity?.did} />}
0
0
0
0
0
0
0
0
427
</div>
428
);
429
}
430
+
431
+
432
+
const EmptyState = ({ text }: { text: string }) => (
433
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400">
434
+
{text}
435
+
</div>
436
+
);
437
+
438
+
const LoadingState = ({ text }: { text: string }) => (
439
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400 italic">
440
+
{text}
441
+
</div>
442
+
);
443
+
444
+
const ErrorState = ({ error }: { error: unknown }) => (
445
+
<div className="py-10 text-center text-red-600 dark:text-red-400">
446
+
Error: {(error as Error)?.message || "Something went wrong."}
447
+
</div>
448
+
);
+36
src/styles/app.css
···
233
/* radix i love you but like cmon man */
234
body[data-scroll-locked]{
235
margin-left: var(--removed-body-scroll-bar-size) !important;
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
236
}
···
233
/* radix i love you but like cmon man */
234
body[data-scroll-locked]{
235
margin-left: var(--removed-body-scroll-bar-size) !important;
236
+
}
237
+
238
+
/* radix tabs */
239
+
240
+
[data-radix-collection-item] {
241
+
flex: 1;
242
+
display: flex;
243
+
padding: 12px 8px;
244
+
align-items: center;
245
+
justify-content: center;
246
+
color: var(--color-gray-500);
247
+
font-weight: 500;
248
+
&[aria-selected="true"] {
249
+
color: var(--color-gray-950);
250
+
&::before{
251
+
content: "";
252
+
position: absolute;
253
+
width: min(80px, 80%);
254
+
border-radius: 99px 99px 0px 0px ;
255
+
height: 3px;
256
+
bottom: 0;
257
+
background-color: var(--color-gray-400);
258
+
}
259
+
}
260
+
}
261
+
262
+
@media (prefers-color-scheme: dark) {
263
+
[data-radix-collection-item] {
264
+
color: var(--color-gray-400);
265
+
&[aria-selected="true"] {
266
+
color: var(--color-gray-50);
267
+
&::before{
268
+
background-color: var(--color-gray-500);
269
+
}
270
+
}
271
+
}
272
}