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
settings revamp
rimar1337
5 months ago
074047b5
fe5744ad
+289
-62
7 changed files
expand all
collapse all
unified
split
src
components
Login.tsx
UniversalPostRenderer.tsx
routes
profile.$did
index.tsx
settings.tsx
styles
app.css
utils
atoms.ts
useHydrated.ts
+59
-15
src/components/Login.tsx
···
1
// src/components/Login.tsx
2
import AtpAgent, { Agent } from "@atproto/api";
0
3
import React, { useEffect, useRef, useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
0
6
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
7
8
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
···
190
<p className="text-xs text-gray-500 dark:text-gray-400">
191
Sign in with AT. Your password is never shared.
192
</p>
193
-
<input
194
type="text"
195
placeholder="handle.bsky.social"
196
value={handle}
197
onChange={(e) => setHandle(e.target.value)}
198
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
199
-
/>
200
-
<button
201
-
type="submit"
202
-
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
203
-
>
204
-
Log in
205
-
</button>
0
0
0
0
0
0
0
0
0
0
0
206
</form>
207
);
208
};
···
235
<p className="text-xs text-red-500 dark:text-red-400">
236
Warning: Less secure. Use an App Password.
237
</p>
238
-
<input
239
type="text"
240
placeholder="handle.bsky.social"
241
value={user}
···
257
value={serviceURL}
258
onChange={(e) => setServiceURL(e.target.value)}
259
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
260
-
/>
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
261
{error && <p className="text-xs text-red-500">{error}</p>}
262
<button
263
type="submit"
···
278
large?: boolean;
279
}) => {
280
const { agent } = useAuth();
281
-
const did = ((agent as AtpAgent).session?.did ?? (agent as AtpAgent)?.assertDid ?? agent?.did) as
282
-
| string
283
-
| undefined;
284
const { data: identity } = useQueryIdentity(did);
285
-
const { data: profiledata } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`);
0
0
286
const profile = profiledata?.value;
287
0
0
288
function getAvatarUrl(p: typeof profile) {
289
const link = p?.avatar?.ref?.["$link"];
290
if (!link || !did) return null;
291
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
292
}
293
294
if (!profiledata) {
···
1
// src/components/Login.tsx
2
import AtpAgent, { Agent } from "@atproto/api";
3
+
import { useAtom } from "jotai";
4
import React, { useEffect, useRef, useState } from "react";
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { imgCDNAtom } from "~/utils/atoms";
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
···
192
<p className="text-xs text-gray-500 dark:text-gray-400">
193
Sign in with AT. Your password is never shared.
194
</p>
195
+
{/* <input
196
type="text"
197
placeholder="handle.bsky.social"
198
value={handle}
199
onChange={(e) => setHandle(e.target.value)}
200
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
201
+
/> */}
202
+
<div className="flex flex-col gap-3">
203
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
204
+
<input
205
+
type="text"
206
+
placeholder=" "
207
+
value={handle}
208
+
onChange={(e) => setHandle(e.target.value)}
209
+
/>
210
+
<label>AT Handle</label>
211
+
</div>
212
+
<button
213
+
type="submit"
214
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
215
+
>
216
+
Log in
217
+
</button>
218
+
</div>
219
</form>
220
);
221
};
···
248
<p className="text-xs text-red-500 dark:text-red-400">
249
Warning: Less secure. Use an App Password.
250
</p>
251
+
{/* <input
252
type="text"
253
placeholder="handle.bsky.social"
254
value={user}
···
270
value={serviceURL}
271
onChange={(e) => setServiceURL(e.target.value)}
272
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
273
+
/> */}
274
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
275
+
<input
276
+
type="text"
277
+
placeholder=" "
278
+
value={user}
279
+
onChange={(e) => setUser(e.target.value)}
280
+
/>
281
+
<label>AT Handle</label>
282
+
</div>
283
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
284
+
<input
285
+
type="text"
286
+
placeholder=" "
287
+
value={password}
288
+
onChange={(e) => setPassword(e.target.value)}
289
+
/>
290
+
<label>App Password</label>
291
+
</div>
292
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
293
+
<input
294
+
type="text"
295
+
placeholder=" "
296
+
value={serviceURL}
297
+
onChange={(e) => setServiceURL(e.target.value)}
298
+
/>
299
+
<label>PDS</label>
300
+
</div>
301
{error && <p className="text-xs text-red-500">{error}</p>}
302
<button
303
type="submit"
···
318
large?: boolean;
319
}) => {
320
const { agent } = useAuth();
321
+
const did = ((agent as AtpAgent).session?.did ??
322
+
(agent as AtpAgent)?.assertDid ??
323
+
agent?.did) as string | undefined;
324
const { data: identity } = useQueryIdentity(did);
325
+
const { data: profiledata } = useQueryProfile(
326
+
`at://${did}/app.bsky.actor.profile/self`
327
+
);
328
const profile = profiledata?.value;
329
330
+
const [imgcdn] = useAtom(imgCDNAtom)
331
+
332
function getAvatarUrl(p: typeof profile) {
333
const link = p?.avatar?.ref?.["$link"];
334
if (!link || !did) return null;
335
+
return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
336
}
337
338
if (!profiledata) {
+16
-13
src/components/UniversalPostRenderer.tsx
···
5
import * as React from "react";
6
import { type SVGProps } from "react";
7
8
-
import { composerAtom, constellationURLAtom, likedPostsAtom } from "~/utils/atoms";
9
import { useHydratedEmbed } from "~/utils/useHydrated";
10
import {
11
useQueryConstellation,
···
599
);
600
}
601
602
-
function getAvatarUrl(opProfile: any, did: string) {
603
const link = opProfile?.value?.avatar?.ref?.["$link"];
604
if (!link) return null;
605
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
606
}
607
608
export function UniversalPostRendererRawRecordShim({
···
723
error: embedError,
724
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
725
0
0
726
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
727
728
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
···
734
did: resolved?.did || "",
735
handle: resolved?.handle || "",
736
displayName: profileRecord?.value?.displayName || "",
737
-
avatar: getAvatarUrl(profileRecord, resolved?.did) || "",
738
viewer: undefined,
739
labels: profileRecord?.labels || undefined,
740
verification: undefined,
···
762
repliesCount,
763
repostsCount,
764
likesCount,
0
765
]
766
);
767
···
886
{...props}
887
>
888
<path
889
-
fill="oklch(0.704 0.05 28)"
890
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
891
></path>
892
</svg>
···
903
{...props}
904
>
905
<path
906
-
fill="oklch(0.704 0.05 28)"
907
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
908
></path>
909
</svg>
···
954
{...props}
955
>
956
<path
957
-
fill="oklch(0.704 0.05 28)"
958
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
959
></path>
960
</svg>
···
971
{...props}
972
>
973
<path
974
-
fill="oklch(0.704 0.05 28)"
975
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
976
></path>
977
</svg>
···
988
{...props}
989
>
990
<path
991
-
fill="oklch(0.704 0.05 28)"
992
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
993
></path>
994
</svg>
···
1005
{...props}
1006
>
1007
<path
1008
-
fill="oklch(0.704 0.05 28)"
1009
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1010
></path>
1011
</svg>
···
1039
{...props}
1040
>
1041
<path
1042
-
fill="oklch(0.704 0.05 28)"
1043
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1044
></path>
1045
</svg>
···
1093
{...props}
1094
>
1095
<path
1096
-
fill="oklch(0.704 0.05 28)"
1097
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1098
></path>
1099
</svg>
···
1110
{...props}
1111
>
1112
<path
1113
-
fill="oklch(0.704 0.05 28)"
1114
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
1115
></path>
1116
</svg>
···
5
import * as React from "react";
6
import { type SVGProps } from "react";
7
8
+
import { composerAtom, constellationURLAtom, imgCDNAtom, likedPostsAtom } from "~/utils/atoms";
9
import { useHydratedEmbed } from "~/utils/useHydrated";
10
import {
11
useQueryConstellation,
···
599
);
600
}
601
602
+
function getAvatarUrl(opProfile: any, did: string, cdn: string) {
603
const link = opProfile?.value?.avatar?.ref?.["$link"];
604
if (!link) return null;
605
+
return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
606
}
607
608
export function UniversalPostRendererRawRecordShim({
···
723
error: embedError,
724
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
725
726
+
const [imgcdn] = useAtom(imgCDNAtom)
727
+
728
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
729
730
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
···
736
did: resolved?.did || "",
737
handle: resolved?.handle || "",
738
displayName: profileRecord?.value?.displayName || "",
739
+
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
740
viewer: undefined,
741
labels: profileRecord?.labels || undefined,
742
verification: undefined,
···
764
repliesCount,
765
repostsCount,
766
likesCount,
767
+
imgcdn
768
]
769
);
770
···
889
{...props}
890
>
891
<path
892
+
fill="var(--color-gray-400)"
893
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
894
></path>
895
</svg>
···
906
{...props}
907
>
908
<path
909
+
fill="var(--color-gray-400)"
910
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
911
></path>
912
</svg>
···
957
{...props}
958
>
959
<path
960
+
fill="var(--color-gray-400)"
961
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
962
></path>
963
</svg>
···
974
{...props}
975
>
976
<path
977
+
fill="var(--color-gray-400)"
978
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
979
></path>
980
</svg>
···
991
{...props}
992
>
993
<path
994
+
fill="var(--color-gray-400)"
995
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
996
></path>
997
</svg>
···
1008
{...props}
1009
>
1010
<path
1011
+
fill="var(--color-gray-400)"
1012
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1013
></path>
1014
</svg>
···
1042
{...props}
1043
>
1044
<path
1045
+
fill="var(--color-gray-400)"
1046
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1047
></path>
1048
</svg>
···
1096
{...props}
1097
>
1098
<path
1099
+
fill="var(--color-gray-400)"
1100
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1101
></path>
1102
</svg>
···
1113
{...props}
1114
>
1115
<path
1116
+
fill="var(--color-gray-400)"
1117
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
1118
></path>
1119
</svg>
+6
-2
src/routes/profile.$did/index.tsx
···
1
import { useQueryClient } from "@tanstack/react-query";
2
import { createFileRoute } from "@tanstack/react-router";
0
3
import React from "react";
4
5
import { Header } from "~/components/Header";
6
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
7
import { useAuth } from "~/providers/UnifiedAuthProvider";
0
8
import { toggleFollow, useGetFollowState } from "~/utils/followState";
9
import {
10
useInfiniteQueryAuthorFeed,
···
66
() => postsData?.pages.flatMap((page) => page.records) ?? [],
67
[postsData]
68
);
0
0
69
70
function getAvatarUrl(p: typeof profile) {
71
const link = p?.avatar?.ref?.["$link"];
72
if (!link || !resolvedDid) return null;
73
-
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
74
}
75
function getBannerUrl(p: typeof profile) {
76
const link = p?.banner?.ref?.["$link"];
77
if (!link || !resolvedDid) return null;
78
-
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
79
}
80
81
const displayName =
···
1
import { useQueryClient } from "@tanstack/react-query";
2
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
import React from "react";
5
6
import { Header } from "~/components/Header";
7
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
+
import { imgCDNAtom } from "~/utils/atoms";
10
import { toggleFollow, useGetFollowState } from "~/utils/followState";
11
import {
12
useInfiniteQueryAuthorFeed,
···
68
() => postsData?.pages.flatMap((page) => page.records) ?? [],
69
[postsData]
70
);
71
+
72
+
const [imgcdn] = useAtom(imgCDNAtom)
73
74
function getAvatarUrl(p: typeof profile) {
75
const link = p?.avatar?.ref?.["$link"];
76
if (!link || !resolvedDid) return null;
77
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
78
}
79
function getBannerUrl(p: typeof profile) {
80
const link = p?.banner?.ref?.["$link"];
81
if (!link || !resolvedDid) return null;
82
+
return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`;
83
}
84
85
const displayName =
+32
-9
src/routes/settings.tsx
···
6
import {
7
constellationURLAtom,
8
defaultconstellationURL,
0
9
defaultslingshotURL,
0
0
10
slingshotURLAtom,
0
11
} from "~/utils/atoms";
12
13
export const Route = createFileRoute("/settings")({
···
27
}
28
}}
29
/>
30
-
<Login />
0
31
<TextInputSetting
32
atom={constellationURLAtom}
33
title={"Constellation"}
···
42
description={"Customize the Slingshot instance to be used by Red Dwarf"}
43
init={defaultslingshotURL}
44
/>
45
-
<span className="text-gray-500 dark:text-gray-400 py-4 px-6">please restart/refresh the app if changes arent applying correctly</span>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
46
</>
47
);
48
}
···
60
}) {
61
const [value, setValue] = useAtom(atom);
62
return (
63
-
<div className="flex flex-col gap-2 p-4 rounded-2xl border border-gray-200 dark:border-gray-800 ">
64
-
<div>
65
{title && (
66
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
67
{title}
···
72
{description}
73
</p>
74
)}
75
-
</div>
76
77
<div className="flex flex-row gap-2 items-center">
78
-
<input
0
0
0
0
79
type="text"
80
value={value}
81
onChange={(e) => setValue(e.target.value)}
···
83
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
84
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
85
placeholder="Enter value..."
86
-
/>
87
<button
88
onClick={() => setValue(init ?? "")}
89
-
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800
90
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
91
>
92
Reset
···
94
</div>
95
</div>
96
);
97
-
}
···
6
import {
7
constellationURLAtom,
8
defaultconstellationURL,
9
+
defaultImgCDN,
10
defaultslingshotURL,
11
+
defaultVideoCDN,
12
+
imgCDNAtom,
13
slingshotURLAtom,
14
+
videoCDNAtom,
15
} from "~/utils/atoms";
16
17
export const Route = createFileRoute("/settings")({
···
31
}
32
}}
33
/>
34
+
<div className="lg:hidden"><Login /></div>
35
+
<div className="h-4" />
36
<TextInputSetting
37
atom={constellationURLAtom}
38
title={"Constellation"}
···
47
description={"Customize the Slingshot instance to be used by Red Dwarf"}
48
init={defaultslingshotURL}
49
/>
50
+
<TextInputSetting
51
+
atom={imgCDNAtom}
52
+
title={"Image CDN"}
53
+
description={
54
+
"Customize the Constellation instance to be used by Red Dwarf"
55
+
}
56
+
init={defaultImgCDN}
57
+
/>
58
+
<TextInputSetting
59
+
atom={videoCDNAtom}
60
+
title={"Video CDN"}
61
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
62
+
init={defaultVideoCDN}
63
+
/>
64
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">please restart/refresh the app if changes arent applying correctly</p>
65
</>
66
);
67
}
···
79
}) {
80
const [value, setValue] = useAtom(atom);
81
return (
82
+
<div className="flex flex-col gap-2 px-4 py-2">
83
+
{/* <div>
84
{title && (
85
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
86
{title}
···
91
{description}
92
</p>
93
)}
94
+
</div> */}
95
96
<div className="flex flex-row gap-2 items-center">
97
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
98
+
<input type="text" placeholder=" " value={value} onChange={(e) => setValue(e.target.value)}/>
99
+
<label>{title}</label>
100
+
</div>
101
+
{/* <input
102
type="text"
103
value={value}
104
onChange={(e) => setValue(e.target.value)}
···
106
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
107
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
108
placeholder="Enter value..."
109
+
/> */}
110
<button
111
onClick={() => setValue(init ?? "")}
112
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
113
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
114
>
115
Reset
···
117
</div>
118
</div>
119
);
120
+
}
+113
src/styles/app.css
···
105
:root {
106
--shadow-opacity: calc(1 - var(--is-top));
107
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
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
108
}
···
105
:root {
106
--shadow-opacity: calc(1 - var(--is-top));
107
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
108
+
}
109
+
110
+
111
+
/* m3 input */
112
+
:root {
113
+
--m3input-radius: 6px;
114
+
--m3input-border-width: .0625rem;
115
+
--m3input-font-size: 16px;
116
+
--m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1);
117
+
/* light theme */
118
+
--m3input-bg: var(--color-gray-50);
119
+
--m3input-border-color: var(--color-gray-400);
120
+
--m3input-label-color: var(--color-gray-500);
121
+
--m3input-text-color: var(--color-gray-900);
122
+
--m3input-focus-color: var(--color-gray-600);
123
+
}
124
+
125
+
@media (prefers-color-scheme: dark) {
126
+
:root {
127
+
--m3input-bg: var(--color-gray-950);
128
+
--m3input-border-color: var(--color-gray-700);
129
+
--m3input-label-color: var(--color-gray-400);
130
+
--m3input-text-color: var(--color-gray-50);
131
+
--m3input-focus-color: var(--color-gray-400);
132
+
}
133
+
}
134
+
135
+
/* reset page *//*
136
+
html,
137
+
body {
138
+
background: var(--m3input-bg);
139
+
margin: 0;
140
+
padding: 1rem;
141
+
color: var(--m3input-text-color);
142
+
font-family: system-ui, sans-serif;
143
+
font-size: var(--m3input-font-size);
144
+
}*/
145
+
146
+
/* base wrapper */
147
+
.m3input-field.m3input-label.m3input-border {
148
+
position: relative;
149
+
display: inline-block;
150
+
width: 100%;
151
+
/*max-width: 400px;*/
152
+
}
153
+
154
+
/* size variants */
155
+
.m3input-field.size-sm {
156
+
--m3input-h: 40px;
157
+
}
158
+
159
+
.m3input-field.size-md {
160
+
--m3input-h: 48px;
161
+
}
162
+
163
+
.m3input-field.size-lg {
164
+
--m3input-h: 56px;
165
+
}
166
+
167
+
.m3input-field.size-xl {
168
+
--m3input-h: 64px;
169
+
}
170
+
171
+
.m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) {
172
+
--m3input-h: 48px;
173
+
}
174
+
175
+
/* outlined input */
176
+
.m3input-field.m3input-label.m3input-border input {
177
+
width: 100%;
178
+
height: var(--m3input-h);
179
+
border: var(--m3input-border-width) solid var(--m3input-border-color);
180
+
border-radius: var(--m3input-radius);
181
+
background: var(--m3input-bg);
182
+
color: var(--m3input-text-color);
183
+
font-size: var(--m3input-font-size);
184
+
padding: 0 12px;
185
+
box-sizing: border-box;
186
+
outline: none;
187
+
transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition);
188
+
}
189
+
190
+
/* focus ring */
191
+
.m3input-field.m3input-label.m3input-border input:focus {
192
+
border-color: var(--m3input-focus-color);
193
+
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
194
+
}
195
+
196
+
/* label */
197
+
.m3input-field.m3input-label.m3input-border label {
198
+
position: absolute;
199
+
left: 12px;
200
+
top: 50%;
201
+
transform: translateY(-50%);
202
+
background: var(--m3input-bg);
203
+
padding: 0 .25em;
204
+
color: var(--m3input-label-color);
205
+
pointer-events: none;
206
+
transition: all var(--m3input-transition);
207
+
}
208
+
209
+
/* float on focus or when filled */
210
+
.m3input-field.m3input-label.m3input-border input:focus+label,
211
+
.m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label {
212
+
top: 0;
213
+
transform: translateY(-50%) scale(.78);
214
+
left: 0;
215
+
color: var(--m3input-focus-color);
216
+
}
217
+
218
+
/* placeholder trick */
219
+
.m3input-field.m3input-label.m3input-border input::placeholder {
220
+
color: transparent;
221
}
+10
src/utils/atoms.ts
···
31
'slingshotURL',
32
defaultslingshotURL
33
)
0
0
0
0
0
0
0
0
0
0
34
35
export const isAtTopAtom = atom<boolean>(true);
36
···
31
'slingshotURL',
32
defaultslingshotURL
33
)
34
+
export const defaultImgCDN = 'cdn.bsky.app'
35
+
export const imgCDNAtom = atomWithStorage<string>(
36
+
'imgcdnurl',
37
+
defaultImgCDN
38
+
)
39
+
export const defaultVideoCDN = 'video.bsky.app'
40
+
export const videoCDNAtom = atomWithStorage<string>(
41
+
'videocdnurl',
42
+
defaultVideoCDN
43
+
)
44
45
export const isAtTopAtom = atom<boolean>(true);
46
+53
-23
src/utils/useHydrated.ts
···
9
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
0
12
import { useMemo } from "react";
13
14
-
import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery";
0
15
16
-
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
17
-
| { data: infer D }
18
-
| undefined
19
-
? D
20
-
: never;
21
22
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
23
return obj as $Typed<T>;
···
26
export function hydrateEmbedImages(
27
embed: AppBskyEmbedImages.Main,
28
did: string,
0
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
36
return {
37
-
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
-
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
0
50
): $Typed<AppBskyEmbedExternal.View> {
51
return asTyped({
52
$type: "app.bsky.embed.external#view" as const,
···
55
title: embed.external.title,
56
description: embed.external.description,
57
thumb: embed.external.thumb?.ref?.$link
58
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
: undefined,
60
},
61
});
···
64
export function hydrateEmbedVideo(
65
embed: AppBskyEmbedVideo.Main,
66
did: string,
0
67
): $Typed<AppBskyEmbedVideo.View> {
68
const videoLink = embed.video.ref.$link;
69
return asTyped({
70
$type: "app.bsky.embed.video#view" as const,
71
-
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
72
-
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
73
aspectRatio: embed.aspectRatio,
74
cid: videoLink,
75
});
···
80
quotedPost: QueryResultData<typeof useQueryPost>,
81
quotedProfile: QueryResultData<typeof useQueryProfile>,
82
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
0
83
): $Typed<AppBskyEmbedRecord.View> | undefined {
84
if (!quotedPost || !quotedProfile || !quotedIdentity) {
85
return undefined;
···
91
handle: quotedIdentity.handle,
92
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
93
avatar: quotedProfile.value.avatar?.ref?.$link
94
-
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
95
: undefined,
96
viewer: {},
97
labels: [],
···
122
quotedPost: QueryResultData<typeof useQueryPost>,
123
quotedProfile: QueryResultData<typeof useQueryProfile>,
124
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
0
125
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
126
const hydratedRecord = hydrateEmbedRecord(
127
embed.record,
128
quotedPost,
129
quotedProfile,
130
quotedIdentity,
0
131
);
132
133
if (!hydratedRecord) return undefined;
···
148
149
export function useHydratedEmbed(
150
embed: AppBskyFeedPost.Record["embed"],
151
-
postAuthorDid: string | undefined,
152
) {
153
const recordInfo = useMemo(() => {
154
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
181
error: profileError,
182
} = useQueryProfile(profileUri);
183
0
0
0
184
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
185
186
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
187
if (!embed || !postAuthorDid) return undefined;
188
189
-
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
0
0
0
0
0
190
return undefined;
191
}
192
193
try {
194
if (AppBskyEmbedImages.isMain(embed)) {
195
-
return hydrateEmbedImages(embed, postAuthorDid);
196
} else if (AppBskyEmbedExternal.isMain(embed)) {
197
-
return hydrateEmbedExternal(embed, postAuthorDid);
198
} else if (AppBskyEmbedVideo.isMain(embed)) {
199
-
return hydrateEmbedVideo(embed, postAuthorDid);
200
} else if (AppBskyEmbedRecord.isMain(embed)) {
201
return hydrateEmbedRecord(
202
embed,
203
usequerypostresults?.data,
204
quotedProfile,
205
queryidentityresult?.data,
0
206
);
207
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
208
let hydratedMedia:
···
212
| undefined;
213
214
if (AppBskyEmbedImages.isMain(embed.media)) {
215
-
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
0
0
0
0
216
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
217
-
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
0
0
0
0
218
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
219
-
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
0
0
0
0
220
}
221
222
if (hydratedMedia) {
···
226
usequerypostresults?.data,
227
quotedProfile,
228
queryidentityresult?.data,
0
229
);
230
}
231
}
···
236
})();
237
238
const isLoading = isRecordType
239
-
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
0
0
240
: false;
241
242
-
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
0
243
244
return { data: hydratedEmbed, isLoading, error };
245
-
}
···
9
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
12
+
import { useAtom } from "jotai";
13
import { useMemo } from "react";
14
15
+
import { imgCDNAtom, videoCDNAtom } from "./atoms";
16
+
import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery";
17
18
+
type QueryResultData<T extends (...args: any) => any> =
19
+
ReturnType<T> extends { data: infer D } | undefined ? D : never;
0
0
0
20
21
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
22
return obj as $Typed<T>;
···
25
export function hydrateEmbedImages(
26
embed: AppBskyEmbedImages.Main,
27
did: string,
28
+
cdn: string
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
36
return {
37
+
thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
+
fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
50
+
cdn: string
51
): $Typed<AppBskyEmbedExternal.View> {
52
return asTyped({
53
$type: "app.bsky.embed.external#view" as const,
···
56
title: embed.external.title,
57
description: embed.external.description,
58
thumb: embed.external.thumb?.ref?.$link
59
+
? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
60
: undefined,
61
},
62
});
···
65
export function hydrateEmbedVideo(
66
embed: AppBskyEmbedVideo.Main,
67
did: string,
68
+
videocdn: string
69
): $Typed<AppBskyEmbedVideo.View> {
70
const videoLink = embed.video.ref.$link;
71
return asTyped({
72
$type: "app.bsky.embed.video#view" as const,
73
+
playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`,
74
+
thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`,
75
aspectRatio: embed.aspectRatio,
76
cid: videoLink,
77
});
···
82
quotedPost: QueryResultData<typeof useQueryPost>,
83
quotedProfile: QueryResultData<typeof useQueryProfile>,
84
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
85
+
cdn: string
86
): $Typed<AppBskyEmbedRecord.View> | undefined {
87
if (!quotedPost || !quotedProfile || !quotedIdentity) {
88
return undefined;
···
94
handle: quotedIdentity.handle,
95
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
96
avatar: quotedProfile.value.avatar?.ref?.$link
97
+
? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
98
: undefined,
99
viewer: {},
100
labels: [],
···
125
quotedPost: QueryResultData<typeof useQueryPost>,
126
quotedProfile: QueryResultData<typeof useQueryProfile>,
127
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
128
+
cdn: string
129
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
130
const hydratedRecord = hydrateEmbedRecord(
131
embed.record,
132
quotedPost,
133
quotedProfile,
134
quotedIdentity,
135
+
cdn
136
);
137
138
if (!hydratedRecord) return undefined;
···
153
154
export function useHydratedEmbed(
155
embed: AppBskyFeedPost.Record["embed"],
156
+
postAuthorDid: string | undefined
157
) {
158
const recordInfo = useMemo(() => {
159
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
186
error: profileError,
187
} = useQueryProfile(profileUri);
188
189
+
const [imgcdn] = useAtom(imgCDNAtom);
190
+
const [videocdn] = useAtom(videoCDNAtom);
191
+
192
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
193
194
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
195
if (!embed || !postAuthorDid) return undefined;
196
197
+
if (
198
+
isRecordType &&
199
+
(!usequerypostresults?.data ||
200
+
!quotedProfile ||
201
+
!queryidentityresult?.data)
202
+
) {
203
return undefined;
204
}
205
206
try {
207
if (AppBskyEmbedImages.isMain(embed)) {
208
+
return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
209
} else if (AppBskyEmbedExternal.isMain(embed)) {
210
+
return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
211
} else if (AppBskyEmbedVideo.isMain(embed)) {
212
+
return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
213
} else if (AppBskyEmbedRecord.isMain(embed)) {
214
return hydrateEmbedRecord(
215
embed,
216
usequerypostresults?.data,
217
quotedProfile,
218
queryidentityresult?.data,
219
+
imgcdn
220
);
221
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
222
let hydratedMedia:
···
226
| undefined;
227
228
if (AppBskyEmbedImages.isMain(embed.media)) {
229
+
hydratedMedia = hydrateEmbedImages(
230
+
embed.media,
231
+
postAuthorDid,
232
+
imgcdn
233
+
);
234
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
235
+
hydratedMedia = hydrateEmbedExternal(
236
+
embed.media,
237
+
postAuthorDid,
238
+
imgcdn
239
+
);
240
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
241
+
hydratedMedia = hydrateEmbedVideo(
242
+
embed.media,
243
+
postAuthorDid,
244
+
videocdn
245
+
);
246
}
247
248
if (hydratedMedia) {
···
252
usequerypostresults?.data,
253
quotedProfile,
254
queryidentityresult?.data,
255
+
imgcdn
256
);
257
}
258
}
···
263
})();
264
265
const isLoading = isRecordType
266
+
? usequerypostresults?.isLoading ||
267
+
isLoadingProfile ||
268
+
queryidentityresult?.isLoading
269
: false;
270
271
+
const error =
272
+
usequerypostresults?.error || profileError || queryidentityresult?.error;
273
274
return { data: hydratedEmbed, isLoading, error };
275
+
}