+1
src/auto-imports.d.ts
+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
+
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
+4
-2
src/components/Header.tsx
···
5
5
6
6
export function Header({
7
7
backButtonCallback,
8
-
title
8
+
title,
9
+
bottomBorderDisabled,
9
10
}: {
10
11
backButtonCallback?: () => void;
11
12
title?: string;
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
-
<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
+
<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
+1
src/components/UniversalPostRenderer.tsx
+424
-152
src/routes/notifications.tsx
+424
-152
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
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";
2
5
import { useAtom } from "jotai";
3
-
import React, { useEffect, useRef,useState } from "react";
6
+
import * as React from "react";
4
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";
5
16
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
-
import { constellationURLAtom } from "~/utils/atoms";
17
+
import { constellationURLAtom, imgCDNAtom, isAtTopAtom } from "~/utils/atoms";
18
+
import {
19
+
useInfiniteQueryAuthorFeed,
20
+
useQueryConstellation,
21
+
useQueryIdentity,
22
+
useQueryProfile,
23
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
24
+
} from "~/utils/useQuery";
7
25
8
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
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
+
}
9
46
10
47
export const Route = createFileRoute("/notifications")({
11
48
component: NotificationsComponent,
12
49
});
13
50
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
51
26
-
useEffect(() => {
27
-
if (authLoading) return;
28
-
if (authed && agent && agent.assertDid) {
29
-
setDid(agent.assertDid);
30
-
}
31
-
}, [authed, agent, authLoading]);
52
+
export default function NotificationsTabs() {
53
+
const [activeTab, setActiveTab] = React.useState("mentions");
54
+
const [isAtTop] = useAtom(isAtTopAtom);
32
55
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
-
}
56
+
const scrollPositions = React.useRef<Record<string, number>>({});
74
57
75
-
const [constellationURL] = useAtom(constellationURLAtom)
58
+
const handleValueChange = (newTab: string) => {
59
+
scrollPositions.current[activeTab] = window.scrollY;
60
+
setActiveTab(newTab);
61
+
};
76
62
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;
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);
98
247
}
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);
248
+
});
113
249
});
114
-
return () => {
115
-
ignore = true;
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,
116
301
};
117
-
}, [did]);
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]);
118
314
119
315
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>
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
+
<></>
142
398
)}
143
399
</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>
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" />
147
408
)}
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> */}
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} />}
174
427
</div>
175
428
);
176
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
+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
+
}
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
+
}
236
272
}