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