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