+164
-117
src/components/Composer.tsx
+164
-117
src/components/Composer.tsx
···
8
import { useQueryPost } from "~/utils/useQuery";
9
10
import { ProfileThing } from "./Login";
11
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
13
const MAX_POST_LENGTH = 300;
14
15
export function Composer() {
16
const [composerState, setComposerState] = useAtom(composerAtom);
17
const { agent } = useAuth();
18
19
const [postText, setPostText] = useState("");
···
112
setPosting(false);
113
}
114
}
115
-
// if (composerState.kind === "closed") {
116
-
// return null;
117
-
// }
118
119
const getPlaceholder = () => {
120
switch (composerState.kind) {
···
132
const isPostButtonDisabled =
133
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
135
return (
136
-
<Dialog.Root
137
-
open={composerState.kind !== "closed"}
138
-
onOpenChange={(open) => {
139
-
if (!open) setComposerState({ kind: "closed" });
140
-
}}
141
-
>
142
-
<Dialog.Portal>
143
-
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
144
145
-
<Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20">
146
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
147
-
<div className="flex flex-row justify-between p-2">
148
-
<Dialog.Close asChild>
149
-
<button
150
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
151
-
disabled={posting}
152
-
aria-label="Close"
153
-
>
154
-
<svg
155
-
xmlns="http://www.w3.org/2000/svg"
156
-
width="20"
157
-
height="20"
158
-
viewBox="0 0 24 24"
159
-
fill="none"
160
-
stroke="currentColor"
161
-
strokeWidth="2.5"
162
-
strokeLinecap="round"
163
-
strokeLinejoin="round"
164
>
165
-
<line x1="18" y1="6" x2="6" y2="18"></line>
166
-
<line x1="6" y1="6" x2="18" y2="18"></line>
167
-
</svg>
168
-
</button>
169
-
</Dialog.Close>
170
171
-
<div className="flex-1" />
172
-
<div className="flex items-center gap-4">
173
-
<span
174
-
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
-
>
176
-
{charsLeft}
177
-
</span>
178
-
<button
179
-
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
180
-
onClick={handlePost}
181
-
disabled={isPostButtonDisabled}
182
-
>
183
-
{posting ? "Posting..." : "Post"}
184
-
</button>
185
</div>
186
-
</div>
187
188
-
{postSuccess ? (
189
-
<div className="flex flex-col items-center justify-center py-16">
190
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
-
<span className="text-xl font-bold text-black dark:text-white">
192
-
Posted!
193
-
</span>
194
-
</div>
195
-
) : (
196
-
<div className="px-4">
197
-
{composerState.kind === "reply" && (
198
-
<div className="mb-1 -mx-4">
199
-
{isParentLoading ? (
200
-
<div className="text-sm text-gray-500 animate-pulse">
201
-
Loading parent post...
202
-
</div>
203
-
) : parentUri ? (
204
-
<UniversalPostRendererATURILoader
205
-
atUri={parentUri}
206
-
bottomReplyLine
207
-
bottomBorder={false}
208
/>
209
-
) : (
210
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
-
Could not load parent post.
212
-
</div>
213
-
)}
214
</div>
215
-
)}
216
217
-
<div className="flex w-full gap-1 flex-col">
218
-
<ProfileThing agent={agent} large />
219
-
<div className="flex pl-[50px]">
220
-
<AutoGrowTextarea
221
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
-
rows={5}
223
-
placeholder={getPlaceholder()}
224
-
value={postText}
225
-
onChange={(e) => setPostText(e.target.value)}
226
-
disabled={posting}
227
-
autoFocus
228
-
/>
229
-
</div>
230
</div>
231
232
-
{composerState.kind === "quote" && (
233
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
-
{isParentLoading ? (
235
-
<div className="text-sm text-gray-500 animate-pulse">
236
-
Loading parent post...
237
-
</div>
238
-
) : parentUri ? (
239
-
<UniversalPostRendererATURILoader
240
-
atUri={parentUri}
241
-
isQuote
242
-
/>
243
-
) : (
244
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
-
Could not load parent post.
246
-
</div>
247
-
)}
248
-
</div>
249
-
)}
250
251
-
{postError && (
252
-
<div className="text-red-500 text-sm my-2 text-center">
253
-
{postError}
254
-
</div>
255
-
)}
256
</div>
257
-
)}
258
-
</div>
259
-
</Dialog.Content>
260
-
</Dialog.Portal>
261
-
</Dialog.Root>
262
);
263
}
264
···
8
import { useQueryPost } from "~/utils/useQuery";
9
10
import { ProfileThing } from "./Login";
11
+
import { Button } from "./radix-m3-rd/Button";
12
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
13
14
const MAX_POST_LENGTH = 300;
15
16
export function Composer() {
17
const [composerState, setComposerState] = useAtom(composerAtom);
18
+
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
19
const { agent } = useAuth();
20
21
const [postText, setPostText] = useState("");
···
114
setPosting(false);
115
}
116
}
117
118
const getPlaceholder = () => {
119
switch (composerState.kind) {
···
131
const isPostButtonDisabled =
132
posting || !postText.trim() || isParentLoading || charsLeft < 0;
133
134
+
function handleAttemptClose() {
135
+
if (postText.trim() && !posting) {
136
+
setCloseConfirmState(true);
137
+
} else {
138
+
setComposerState({ kind: "closed" });
139
+
}
140
+
}
141
+
142
+
function handleConfirmClose() {
143
+
setComposerState({ kind: "closed" });
144
+
setCloseConfirmState(false);
145
+
setPostText("");
146
+
}
147
+
148
return (
149
+
<>
150
+
<Dialog.Root
151
+
open={composerState.kind !== "closed"}
152
+
onOpenChange={(open) => {
153
+
if (!open) handleAttemptClose();
154
+
}}
155
+
>
156
+
<Dialog.Portal>
157
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
158
159
+
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
+
<div className="flex flex-row justify-between p-2">
162
+
<Dialog.Close asChild>
163
+
<button
164
+
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
+
disabled={posting}
166
+
aria-label="Close"
167
+
onClick={handleAttemptClose}
168
>
169
+
<svg
170
+
xmlns="http://www.w3.org/2000/svg"
171
+
width="20"
172
+
height="20"
173
+
viewBox="0 0 24 24"
174
+
fill="none"
175
+
stroke="currentColor"
176
+
strokeWidth="2.5"
177
+
strokeLinecap="round"
178
+
strokeLinejoin="round"
179
+
>
180
+
<line x1="18" y1="6" x2="6" y2="18"></line>
181
+
<line x1="6" y1="6" x2="18" y2="18"></line>
182
+
</svg>
183
+
</button>
184
+
</Dialog.Close>
185
186
+
<div className="flex-1" />
187
+
<div className="flex items-center gap-4">
188
+
<span
189
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
+
>
191
+
{charsLeft}
192
+
</span>
193
+
<Button
194
+
onClick={handlePost}
195
+
disabled={isPostButtonDisabled}
196
+
>
197
+
{posting ? "Posting..." : "Post"}
198
+
</Button>
199
+
</div>
200
</div>
201
+
202
+
{postSuccess ? (
203
+
<div className="flex flex-col items-center justify-center py-16">
204
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
+
<span className="text-xl font-bold text-black dark:text-white">
206
+
Posted!
207
+
</span>
208
+
</div>
209
+
) : (
210
+
<div className="px-4">
211
+
{composerState.kind === "reply" && (
212
+
<div className="mb-1 -mx-4">
213
+
{isParentLoading ? (
214
+
<div className="text-sm text-gray-500 animate-pulse">
215
+
Loading parent post...
216
+
</div>
217
+
) : parentUri ? (
218
+
<UniversalPostRendererATURILoader
219
+
atUri={parentUri}
220
+
bottomReplyLine
221
+
bottomBorder={false}
222
+
/>
223
+
) : (
224
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
+
Could not load parent post.
226
+
</div>
227
+
)}
228
+
</div>
229
+
)}
230
231
+
<div className="flex w-full gap-1 flex-col">
232
+
<ProfileThing agent={agent} large />
233
+
<div className="flex pl-[50px]">
234
+
<AutoGrowTextarea
235
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
+
rows={5}
237
+
placeholder={getPlaceholder()}
238
+
value={postText}
239
+
onChange={(e) => setPostText(e.target.value)}
240
+
disabled={posting}
241
+
autoFocus
242
/>
243
+
</div>
244
</div>
245
246
+
{composerState.kind === "quote" && (
247
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
+
{isParentLoading ? (
249
+
<div className="text-sm text-gray-500 animate-pulse">
250
+
Loading parent post...
251
+
</div>
252
+
) : parentUri ? (
253
+
<UniversalPostRendererATURILoader
254
+
atUri={parentUri}
255
+
isQuote
256
+
/>
257
+
) : (
258
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
+
Could not load parent post.
260
+
</div>
261
+
)}
262
+
</div>
263
+
)}
264
+
265
+
{postError && (
266
+
<div className="text-red-500 text-sm my-2 text-center">
267
+
{postError}
268
+
</div>
269
+
)}
270
</div>
271
+
)}
272
+
</div>
273
+
</Dialog.Content>
274
+
</Dialog.Portal>
275
+
</Dialog.Root>
276
277
+
{/* Close confirmation dialog */}
278
+
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
+
<Dialog.Portal>
280
+
281
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
282
283
+
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
+
<div className="text-xl mb-4 text-center">
286
+
Discard your post?
287
+
</div>
288
+
<div className="text-md mb-4 text-center">
289
+
You will lose your draft
290
+
</div>
291
+
<div className="flex justify-end gap-2 px-6">
292
+
<Button
293
+
onClick={handleConfirmClose}
294
+
>
295
+
Discard
296
+
</Button>
297
+
<Button
298
+
variant={"outlined"}
299
+
onClick={() => setCloseConfirmState(false)}
300
+
>
301
+
Cancel
302
+
</Button>
303
</div>
304
+
</div>
305
+
</Dialog.Content>
306
+
</Dialog.Portal>
307
+
</Dialog.Root>
308
+
</>
309
);
310
}
311
+5
-3
src/components/Login.tsx
+5
-3
src/components/Login.tsx
···
7
import { imgCDNAtom } from "~/utils/atoms";
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
export default function Login({
12
compact = false,
···
49
You are logged in!
50
</p>
51
<ProfileThing agent={agent} large />
52
-
<button
53
onClick={logout}
54
-
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
55
>
56
Log out
57
-
</button>
58
</div>
59
</div>
60
);
···
7
import { imgCDNAtom } from "~/utils/atoms";
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
+
import { Button } from "./radix-m3-rd/Button";
11
+
12
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
13
export default function Login({
14
compact = false,
···
51
You are logged in!
52
</p>
53
<ProfileThing agent={agent} large />
54
+
<Button
55
onClick={logout}
56
+
className="mt-4"
57
>
58
Log out
59
+
</Button>
60
</div>
61
</div>
62
);
+2
src/components/UniversalPostRenderer.tsx
+2
src/components/UniversalPostRenderer.tsx
···
1557
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
/>
1559
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
<FollowButton targetdidorhandle={post.author.did} />
1561
</div>
1562
</div>
1563
<div className="flex flex-col gap-3">
···
1557
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
/>
1559
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
+
<div className=" flex flex-col justify-start">
1561
<FollowButton targetdidorhandle={post.author.did} />
1562
+
</div>
1563
</div>
1564
</div>
1565
<div className="flex flex-col gap-3">
+59
src/components/radix-m3-rd/Button.tsx
+59
src/components/radix-m3-rd/Button.tsx
···
···
1
+
import { Slot } from "@radix-ui/react-slot";
2
+
import clsx from "clsx";
3
+
import * as React from "react";
4
+
5
+
export type ButtonVariant = "filled" | "outlined" | "text" | "secondary";
6
+
export type ButtonSize = "sm" | "md" | "lg";
7
+
8
+
const variantClasses: Record<ButtonVariant, string> = {
9
+
filled:
10
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
11
+
secondary:
12
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
13
+
outlined:
14
+
"border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10",
15
+
text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10",
16
+
};
17
+
18
+
const sizeClasses: Record<ButtonSize, string> = {
19
+
sm: "px-3 py-1.5 text-sm",
20
+
md: "px-4 py-2 text-base",
21
+
lg: "px-6 py-3 text-lg",
22
+
};
23
+
24
+
export function Button({
25
+
variant = "filled",
26
+
size = "md",
27
+
asChild = false,
28
+
ref,
29
+
className,
30
+
children,
31
+
...props
32
+
}: {
33
+
variant?: ButtonVariant;
34
+
size?: ButtonSize;
35
+
asChild?: boolean;
36
+
className?: string;
37
+
children?: React.ReactNode;
38
+
ref?: React.Ref<HTMLButtonElement>;
39
+
} & React.ComponentPropsWithoutRef<"button">) {
40
+
const Comp = asChild ? Slot : "button";
41
+
42
+
return (
43
+
<Comp
44
+
ref={ref}
45
+
className={clsx(
46
+
//focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300
47
+
"inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
48
+
variantClasses[variant],
49
+
sizeClasses[size],
50
+
className
51
+
)}
52
+
{...props}
53
+
>
54
+
{children}
55
+
</Comp>
56
+
);
57
+
}
58
+
59
+
Button.displayName = "Button";
+26
-23
src/providers/UnifiedAuthProvider.tsx
+26
-23
src/providers/UnifiedAuthProvider.tsx
···
1
-
// src/providers/UnifiedAuthProvider.tsx
2
-
// Import both Agent and the (soon to be deprecated) AtpAgent
3
import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
4
import {
5
type OAuthSession,
···
7
TokenRefreshError,
8
TokenRevokedError,
9
} from "@atproto/oauth-client-browser";
10
import React, {
11
createContext,
12
use,
···
15
useState,
16
} from "react";
17
18
-
import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed
19
20
-
// Define the unified status and authentication method
21
type AuthStatus = "loading" | "signedIn" | "signedOut";
22
type AuthMethod = "password" | "oauth" | null;
23
24
interface AuthContextValue {
25
-
agent: Agent | null; // The agent is typed as the base class `Agent`
26
status: AuthStatus;
27
authMethod: AuthMethod;
28
loginWithPassword: (
···
41
}: {
42
children: React.ReactNode;
43
}) => {
44
-
// The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances.
45
const [agent, setAgent] = useState<Agent | null>(null);
46
const [status, setStatus] = useState<AuthStatus>("loading");
47
const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
48
const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
49
50
-
// Unified Initialization Logic
51
const initialize = useCallback(async () => {
52
-
// --- 1. Try OAuth initialization first ---
53
try {
54
const oauthResult = await oauthClient.init();
55
if (oauthResult) {
56
// /*mass comment*/ console.log("OAuth session restored.");
57
-
const apiAgent = new Agent(oauthResult.session); // Standard Agent
58
setAgent(apiAgent);
59
setOauthSession(oauthResult.session);
60
setAuthMethod("oauth");
61
setStatus("signedIn");
62
-
return; // Success
63
}
64
} catch (e) {
65
console.error("OAuth init failed, checking password session.", e);
66
}
67
68
-
// --- 2. If no OAuth, try password-based session using AtpAgent ---
69
try {
70
const service = localStorage.getItem("service");
71
const sessionString = localStorage.getItem("sess");
72
73
if (service && sessionString) {
74
// /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
75
-
// Use the original, working AtpAgent logic
76
const apiAgent = new AtpAgent({ service });
77
const session: AtpSessionData = JSON.parse(sessionString);
78
await apiAgent.resumeSession(session);
79
80
// /*mass comment*/ console.log("Password-based session resumed successfully.");
81
-
setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent
82
setAuthMethod("password");
83
setStatus("signedIn");
84
-
return; // Success
85
}
86
} catch (e) {
87
console.error("Failed to resume password-based session.", e);
···
89
localStorage.removeItem("service");
90
}
91
92
-
// --- 3. If neither worked, user is signed out ---
93
// /*mass comment*/ console.log("No active session found.");
94
setStatus("signedOut");
95
setAgent(null);
96
setAuthMethod(null);
97
-
}, []);
98
99
useEffect(() => {
100
const handleOAuthSessionDeleted = (
···
105
setOauthSession(null);
106
setAuthMethod(null);
107
setStatus("signedOut");
108
};
109
110
oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
···
113
return () => {
114
oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
115
};
116
-
}, [initialize]);
117
118
-
// --- Login Methods ---
119
const loginWithPassword = async (
120
user: string,
121
password: string,
···
125
setStatus("loading");
126
try {
127
let sessionData: AtpSessionData | undefined;
128
-
// Use the AtpAgent for its simple login and session persistence
129
const apiAgent = new AtpAgent({
130
service,
131
persistSession: (_evt, sess) => {
···
137
if (sessionData) {
138
localStorage.setItem("service", service);
139
localStorage.setItem("sess", JSON.stringify(sessionData));
140
-
setAgent(apiAgent); // Store the AtpAgent instance in our state
141
setAuthMethod("password");
142
setStatus("signedIn");
143
// /*mass comment*/ console.log("Successfully logged in with password.");
144
} else {
145
throw new Error("Session data not persisted after login.");
···
147
} catch (e) {
148
console.error("Password login failed:", e);
149
setStatus("signedOut");
150
throw e;
151
}
152
};
···
161
}
162
}, [status]);
163
164
-
// --- Unified Logout ---
165
const logout = useCallback(async () => {
166
if (status !== "signedIn" || !agent) return;
167
setStatus("loading");
···
173
} else if (authMethod === "password") {
174
localStorage.removeItem("service");
175
localStorage.removeItem("sess");
176
-
// AtpAgent has its own logout methods
177
await (agent as AtpAgent).com.atproto.server.deleteSession();
178
// /*mass comment*/ console.log("Password-based session deleted.");
179
}
···
184
setAuthMethod(null);
185
setOauthSession(null);
186
setStatus("signedOut");
187
}
188
-
}, [status, authMethod, agent, oauthSession]);
189
190
return (
191
<AuthContext
···
1
import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
2
import {
3
type OAuthSession,
···
5
TokenRefreshError,
6
TokenRevokedError,
7
} from "@atproto/oauth-client-browser";
8
+
import { useAtom } from "jotai";
9
import React, {
10
createContext,
11
use,
···
14
useState,
15
} from "react";
16
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
19
+
import { oauthClient } from "../utils/oauthClient";
20
21
type AuthStatus = "loading" | "signedIn" | "signedOut";
22
type AuthMethod = "password" | "oauth" | null;
23
24
interface AuthContextValue {
25
+
agent: Agent | null;
26
status: AuthStatus;
27
authMethod: AuthMethod;
28
loginWithPassword: (
···
41
}: {
42
children: React.ReactNode;
43
}) => {
44
const [agent, setAgent] = useState<Agent | null>(null);
45
const [status, setStatus] = useState<AuthStatus>("loading");
46
const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
47
const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
48
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
49
50
const initialize = useCallback(async () => {
51
try {
52
const oauthResult = await oauthClient.init();
53
if (oauthResult) {
54
// /*mass comment*/ console.log("OAuth session restored.");
55
+
const apiAgent = new Agent(oauthResult.session);
56
setAgent(apiAgent);
57
setOauthSession(oauthResult.session);
58
setAuthMethod("oauth");
59
setStatus("signedIn");
60
+
setQuickAuth(apiAgent?.did || null);
61
+
return;
62
}
63
} catch (e) {
64
console.error("OAuth init failed, checking password session.", e);
65
+
if (!quickAuth) {
66
+
// quickAuth restoration. if last used method is oauth we immediately call for oauth redo
67
+
// (and set a persistent atom somewhere to not retry again if it failed)
68
+
}
69
}
70
71
try {
72
const service = localStorage.getItem("service");
73
const sessionString = localStorage.getItem("sess");
74
75
if (service && sessionString) {
76
// /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
77
const apiAgent = new AtpAgent({ service });
78
const session: AtpSessionData = JSON.parse(sessionString);
79
await apiAgent.resumeSession(session);
80
81
// /*mass comment*/ console.log("Password-based session resumed successfully.");
82
+
setAgent(apiAgent);
83
setAuthMethod("password");
84
setStatus("signedIn");
85
+
setQuickAuth(apiAgent?.did || null);
86
+
return;
87
}
88
} catch (e) {
89
console.error("Failed to resume password-based session.", e);
···
91
localStorage.removeItem("service");
92
}
93
94
// /*mass comment*/ console.log("No active session found.");
95
setStatus("signedOut");
96
setAgent(null);
97
setAuthMethod(null);
98
+
// do we want to null it here?
99
+
setQuickAuth(null);
100
+
}, [quickAuth, setQuickAuth]);
101
102
useEffect(() => {
103
const handleOAuthSessionDeleted = (
···
108
setOauthSession(null);
109
setAuthMethod(null);
110
setStatus("signedOut");
111
+
setQuickAuth(null);
112
};
113
114
oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
···
117
return () => {
118
oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
119
};
120
+
}, [initialize, setQuickAuth]);
121
122
const loginWithPassword = async (
123
user: string,
124
password: string,
···
128
setStatus("loading");
129
try {
130
let sessionData: AtpSessionData | undefined;
131
const apiAgent = new AtpAgent({
132
service,
133
persistSession: (_evt, sess) => {
···
139
if (sessionData) {
140
localStorage.setItem("service", service);
141
localStorage.setItem("sess", JSON.stringify(sessionData));
142
+
setAgent(apiAgent);
143
setAuthMethod("password");
144
setStatus("signedIn");
145
+
setQuickAuth(apiAgent?.did || null);
146
// /*mass comment*/ console.log("Successfully logged in with password.");
147
} else {
148
throw new Error("Session data not persisted after login.");
···
150
} catch (e) {
151
console.error("Password login failed:", e);
152
setStatus("signedOut");
153
+
setQuickAuth(null);
154
throw e;
155
}
156
};
···
165
}
166
}, [status]);
167
168
const logout = useCallback(async () => {
169
if (status !== "signedIn" || !agent) return;
170
setStatus("loading");
···
176
} else if (authMethod === "password") {
177
localStorage.removeItem("service");
178
localStorage.removeItem("sess");
179
await (agent as AtpAgent).com.atproto.server.deleteSession();
180
// /*mass comment*/ console.log("Password-based session deleted.");
181
}
···
186
setAuthMethod(null);
187
setOauthSession(null);
188
setStatus("signedOut");
189
+
setQuickAuth(null);
190
}
191
+
}, [status, agent, authMethod, oauthSession, setQuickAuth]);
192
193
return (
194
<AuthContext
+39
-40
src/routes/index.tsx
+39
-40
src/routes/index.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
import { useAtom } from "jotai";
3
import * as React from "react";
4
-
import { useEffect, useLayoutEffect } from "react";
5
6
import { Header } from "~/components/Header";
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
import {
10
-
agentAtom,
11
-
authedAtom,
12
feedScrollPositionsAtom,
13
isAtTopAtom,
14
selectedFeedUriAtom,
15
-
store,
16
} from "~/utils/atoms";
17
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
18
import {
···
107
} = useAuth();
108
const authed = !!agent?.did;
109
110
-
useEffect(() => {
111
-
if (agent?.did) {
112
-
store.set(authedAtom, true);
113
-
} else {
114
-
store.set(authedAtom, false);
115
-
}
116
-
}, [status, agent, authed]);
117
-
useEffect(() => {
118
-
if (agent) {
119
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
120
-
// @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
121
-
store.set(agentAtom, agent);
122
-
} else {
123
-
store.set(agentAtom, null);
124
-
}
125
-
}, [status, agent, authed]);
126
127
//const { get, set } = usePersistentStore();
128
// const [feed, setFeed] = React.useState<any[]>([]);
···
162
163
// const savedFeeds = savedFeedsPref?.items || [];
164
165
-
const identityresultmaybe = useQueryIdentity(agent?.did);
166
const identity = identityresultmaybe?.data;
167
168
const prefsresultmaybe = useQueryPreferences({
169
-
agent: agent ?? undefined,
170
-
pdsUrl: identity?.pds,
171
});
172
const prefs = prefsresultmaybe?.data;
173
···
178
return savedFeedsPref?.items || [];
179
}, [prefs]);
180
181
-
const [persistentSelectedFeed, setPersistentSelectedFeed] =
182
-
useAtom(selectedFeedUriAtom); // React.useState<string | null>(null);
183
-
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState(
184
-
persistentSelectedFeed
185
-
); // React.useState<string | null>(null);
186
const selectedFeed = agent?.did
187
? persistentSelectedFeed
188
: unauthedSelectedFeed;
···
306
}, [scrollPositions]);
307
308
useLayoutEffect(() => {
309
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
310
311
window.scrollTo({ top: savedPosition, behavior: "instant" });
312
// eslint-disable-next-line react-hooks/exhaustive-deps
313
-
}, [selectedFeed]);
314
315
useLayoutEffect(() => {
316
-
if (!selectedFeed) return;
317
318
const handleScroll = () => {
319
scrollPositionsRef.current = {
···
328
329
setScrollPositions(scrollPositionsRef.current);
330
};
331
-
}, [selectedFeed, setScrollPositions]);
332
333
-
const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined);
334
-
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
335
336
// const {
337
// data: feedData,
···
347
348
// const feed = feedData?.feed || [];
349
350
-
const isReadyForAuthedFeed =
351
-
authed && agent && identity?.pds && feedServiceDid;
352
-
const isReadyForUnauthedFeed = !authed && selectedFeed;
353
354
355
const [isAtTop] = useAtom(isAtTopAtom);
···
358
<div
359
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
360
>
361
-
{savedFeeds.length > 0 ? (
362
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
363
{savedFeeds.map((item: any, idx: number) => {
364
const label = item.value.split("/").pop() || item.value;
···
410
/>
411
))} */}
412
413
-
{authed && (!identity?.pds || !feedServiceDid) && (
414
<div className="p-4 text-center text-gray-500">
415
Preparing your feed...
416
</div>
417
)}
418
419
-
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
420
<InfiniteCustomFeed
421
key={selectedFeed!}
422
feedUri={selectedFeed!}
···
425
/>
426
) : (
427
<div className="p-4 text-center text-gray-500">
428
-
Select a feed to get started.
429
</div>
430
)}
431
{/* {false && restoringScrollPosition && (
···
1
import { createFileRoute } from "@tanstack/react-router";
2
import { useAtom } from "jotai";
3
import * as React from "react";
4
+
import { useLayoutEffect, useState } from "react";
5
6
import { Header } from "~/components/Header";
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
import {
10
feedScrollPositionsAtom,
11
isAtTopAtom,
12
+
quickAuthAtom,
13
selectedFeedUriAtom,
14
} from "~/utils/atoms";
15
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
16
import {
···
105
} = useAuth();
106
const authed = !!agent?.did;
107
108
+
// i dont remember why this is even here
109
+
// useEffect(() => {
110
+
// if (agent?.did) {
111
+
// store.set(authedAtom, true);
112
+
// } else {
113
+
// store.set(authedAtom, false);
114
+
// }
115
+
// }, [status, agent, authed]);
116
+
// useEffect(() => {
117
+
// if (agent) {
118
+
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
119
+
// // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
120
+
// store.set(agentAtom, agent);
121
+
// } else {
122
+
// store.set(agentAtom, null);
123
+
// }
124
+
// }, [status, agent, authed]);
125
126
//const { get, set } = usePersistentStore();
127
// const [feed, setFeed] = React.useState<any[]>([]);
···
161
162
// const savedFeeds = savedFeedsPref?.items || [];
163
164
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
165
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
166
+
167
+
const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined);
168
const identity = identityresultmaybe?.data;
169
170
const prefsresultmaybe = useQueryPreferences({
171
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
172
+
pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined,
173
});
174
const prefs = prefsresultmaybe?.data;
175
···
180
return savedFeedsPref?.items || [];
181
}, [prefs]);
182
183
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
184
+
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed);
185
const selectedFeed = agent?.did
186
? persistentSelectedFeed
187
: unauthedSelectedFeed;
···
305
}, [scrollPositions]);
306
307
useLayoutEffect(() => {
308
+
if (isAuthRestoring) return;
309
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
310
311
window.scrollTo({ top: savedPosition, behavior: "instant" });
312
// eslint-disable-next-line react-hooks/exhaustive-deps
313
+
}, [selectedFeed, isAuthRestoring]);
314
315
useLayoutEffect(() => {
316
+
if (!selectedFeed || isAuthRestoring) return;
317
318
const handleScroll = () => {
319
scrollPositionsRef.current = {
···
328
329
setScrollPositions(scrollPositionsRef.current);
330
};
331
+
}, [isAuthRestoring, selectedFeed, setScrollPositions]);
332
333
+
const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined);
334
+
const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined;
335
336
// const {
337
// data: feedData,
···
347
348
// const feed = feedData?.feed || [];
349
350
+
const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
351
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed;
352
353
354
const [isAtTop] = useAtom(isAtTopAtom);
···
357
<div
358
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
359
>
360
+
{!isAuthRestoring && savedFeeds.length > 0 ? (
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
362
{savedFeeds.map((item: any, idx: number) => {
363
const label = item.value.split("/").pop() || item.value;
···
409
/>
410
))} */}
411
412
+
{isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && (
413
<div className="p-4 text-center text-gray-500">
414
Preparing your feed...
415
</div>
416
)}
417
418
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
419
<InfiniteCustomFeed
420
key={selectedFeed!}
421
feedUri={selectedFeed!}
···
424
/>
425
) : (
426
<div className="p-4 text-center text-gray-500">
427
+
Loading.......
428
</div>
429
)}
430
{/* {false && restoringScrollPosition && (
+15
-25
src/routes/profile.$did/index.tsx
+15
-25
src/routes/profile.$did/index.tsx
···
5
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
import {
9
renderTextWithFacets,
10
UniversalPostRendererATURILoader,
11
} from "~/components/UniversalPostRenderer";
12
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
-
import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms";
14
import {
15
toggleFollow,
16
useGetFollowState,
17
useGetOneToOneState,
18
} from "~/utils/followState";
19
import {
20
-
useInfiniteQueryAturiList,
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
32
-
//const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
35
data: identity,
···
39
40
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
-
//const pdsUrl = identity?.pds;
43
44
const profileUri = resolvedDid
45
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
50
-
const [aturilistservice] = useAtom(aturiListServiceAtom);
51
-
52
const {
53
data: postsData,
54
fetchNextPage,
55
hasNextPage,
56
isFetchingNextPage,
57
isLoading: arePostsLoading,
58
-
} = useInfiniteQueryAturiList({
59
-
aturilistservice: aturilistservice,
60
-
did: resolvedDid,
61
-
collection: "app.bsky.feed.post",
62
-
reverse: true
63
-
});
64
65
React.useEffect(() => {
66
if (postsData) {
67
postsData.pages.forEach((page) => {
68
-
page.forEach((record) => {
69
if (!queryClient.getQueryData(["post", record.uri])) {
70
queryClient.setQueryData(["post", record.uri], record);
71
}
···
75
}, [postsData, queryClient]);
76
77
const posts = React.useMemo(
78
-
() => postsData?.pages.flatMap((page) => page) ?? [],
79
[postsData]
80
);
81
···
177
also save it persistently
178
*/}
179
<FollowButton targetdidorhandle={did} />
180
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
181
... {/* todo: icon */}
182
-
</button>
183
</div>
184
185
{/* Info Card */}
···
255
{identity?.did !== agent?.did ? (
256
<>
257
{!(followRecords?.length && followRecords?.length > 0) ? (
258
-
<button
259
onClick={(e) => {
260
e.stopPropagation();
261
toggleFollow({
···
265
queryClient: queryClient,
266
});
267
}}
268
-
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
269
>
270
Follow
271
-
</button>
272
) : (
273
-
<button
274
onClick={(e) => {
275
e.stopPropagation();
276
toggleFollow({
···
280
queryClient: queryClient,
281
});
282
}}
283
-
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
284
>
285
Unfollow
286
-
</button>
287
)}
288
</>
289
) : (
290
-
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
291
-
Edit Profile
292
-
</button>
293
)}
294
</>
295
);
···
5
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
+
import { Button } from "~/components/radix-m3-rd/Button";
9
import {
10
renderTextWithFacets,
11
UniversalPostRendererATURILoader,
12
} from "~/components/UniversalPostRenderer";
13
import { useAuth } from "~/providers/UnifiedAuthProvider";
14
+
import { imgCDNAtom } from "~/utils/atoms";
15
import {
16
toggleFollow,
17
useGetFollowState,
18
useGetOneToOneState,
19
} from "~/utils/followState";
20
import {
21
+
useInfiniteQueryAuthorFeed,
22
useQueryIdentity,
23
useQueryProfile,
24
} from "~/utils/useQuery";
···
30
function ProfileComponent() {
31
// booo bad this is not always the did it might be a handle, use identity.did instead
32
const { did } = Route.useParams();
33
+
const navigate = useNavigate();
34
const queryClient = useQueryClient();
35
const {
36
data: identity,
···
40
41
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
42
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
43
+
const pdsUrl = identity?.pds;
44
45
const profileUri = resolvedDid
46
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
48
const { data: profileRecord } = useQueryProfile(profileUri);
49
const profile = profileRecord?.value;
50
51
const {
52
data: postsData,
53
fetchNextPage,
54
hasNextPage,
55
isFetchingNextPage,
56
isLoading: arePostsLoading,
57
+
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
58
59
React.useEffect(() => {
60
if (postsData) {
61
postsData.pages.forEach((page) => {
62
+
page.records.forEach((record) => {
63
if (!queryClient.getQueryData(["post", record.uri])) {
64
queryClient.setQueryData(["post", record.uri], record);
65
}
···
69
}, [postsData, queryClient]);
70
71
const posts = React.useMemo(
72
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
73
[postsData]
74
);
75
···
171
also save it persistently
172
*/}
173
<FollowButton targetdidorhandle={did} />
174
+
<Button className="rounded-full" variant={"secondary"}>
175
... {/* todo: icon */}
176
+
</Button>
177
</div>
178
179
{/* Info Card */}
···
249
{identity?.did !== agent?.did ? (
250
<>
251
{!(followRecords?.length && followRecords?.length > 0) ? (
252
+
<Button
253
onClick={(e) => {
254
e.stopPropagation();
255
toggleFollow({
···
259
queryClient: queryClient,
260
});
261
}}
262
>
263
Follow
264
+
</Button>
265
) : (
266
+
<Button
267
onClick={(e) => {
268
e.stopPropagation();
269
toggleFollow({
···
273
queryClient: queryClient,
274
});
275
}}
276
>
277
Unfollow
278
+
</Button>
279
)}
280
</>
281
) : (
282
+
<Button variant={"secondary"}>Edit Profile</Button>
283
)}
284
</>
285
);
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
···
85
e.stopPropagation();
86
e.nativeEvent.stopImmediatePropagation();
87
}}
88
-
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
89
>
90
<ProfilePostComponent
91
key={`/profile/${did}/post/${rkey}`}
···
85
e.stopPropagation();
86
e.nativeEvent.stopImmediatePropagation();
87
}}
88
+
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
89
>
90
<ProfilePostComponent
91
key={`/profile/${did}/post/${rkey}`}
-8
src/routes/settings.tsx
-8
src/routes/settings.tsx
···
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
7
import {
8
-
aturiListServiceAtom,
9
constellationURLAtom,
10
-
defaultaturilistservice,
11
defaultconstellationURL,
12
defaulthue,
13
defaultImgCDN,
···
53
title={"Slingshot"}
54
description={"Customize the Slingshot instance to be used by Red Dwarf"}
55
init={defaultslingshotURL}
56
-
/>
57
-
<TextInputSetting
58
-
atom={aturiListServiceAtom}
59
-
title={"AtUriListService"}
60
-
description={"Customize the AtUriListService instance to be used by Red Dwarf"}
61
-
init={defaultaturilistservice}
62
/>
63
<TextInputSetting
64
atom={imgCDNAtom}
···
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
7
import {
8
constellationURLAtom,
9
defaultconstellationURL,
10
defaulthue,
11
defaultImgCDN,
···
51
title={"Slingshot"}
52
description={"Customize the Slingshot instance to be used by Red Dwarf"}
53
init={defaultslingshotURL}
54
/>
55
<TextInputSetting
56
atom={imgCDNAtom}
+6
-2
src/styles/app.css
+6
-2
src/styles/app.css
···
52
}
53
}
54
55
@media (width >= 64rem /* 1024px */) {
56
html:not(:has(.disablegutter)),
57
body:not(:has(.disablegutter)) {
58
scrollbar-gutter: stable both-edges !important;
59
}
60
-
html:has(.disablegutter),
61
-
body:has(.disablegutter) {
62
scrollbar-width: none;
63
overflow-y: hidden;
64
}
···
52
}
53
}
54
55
+
.gutter{
56
+
scrollbar-gutter: stable both-edges;
57
+
}
58
+
59
@media (width >= 64rem /* 1024px */) {
60
html:not(:has(.disablegutter)),
61
body:not(:has(.disablegutter)) {
62
scrollbar-gutter: stable both-edges !important;
63
}
64
+
html:has(.disablescroll),
65
+
body:has(.disablescroll) {
66
scrollbar-width: none;
67
overflow-y: hidden;
68
}
+7
-8
src/utils/atoms.ts
+7
-8
src/utils/atoms.ts
···
1
-
import type Agent from "@atproto/api";
2
import { atom, createStore, useAtomValue } from "jotai";
3
import { atomWithStorage } from "jotai/utils";
4
import { useEffect } from "react";
5
6
export const store = createStore();
7
8
export const selectedFeedUriAtom = atomWithStorage<string | null>(
9
"selectedFeedUri",
···
32
"slingshotURL",
33
defaultslingshotURL
34
);
35
-
export const defaultaturilistservice = "aturilistservice.reddwarf.app";
36
-
export const aturiListServiceAtom = atomWithStorage<string>(
37
-
"aturilistservice",
38
-
defaultaturilistservice
39
-
);
40
export const defaultImgCDN = "cdn.bsky.app";
41
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
42
export const defaultVideoCDN = "video.bsky.app";
···
57
| { kind: "quote"; subject: string };
58
export const composerAtom = atom<ComposerState>({ kind: "closed" });
59
60
-
export const agentAtom = atom<Agent | null>(null);
61
-
export const authedAtom = atom<boolean>(false);
62
63
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
64
const value = useAtomValue(atom);
···
1
import { atom, createStore, useAtomValue } from "jotai";
2
import { atomWithStorage } from "jotai/utils";
3
import { useEffect } from "react";
4
5
export const store = createStore();
6
+
7
+
export const quickAuthAtom = atomWithStorage<string | null>(
8
+
"quickAuth",
9
+
null
10
+
);
11
12
export const selectedFeedUriAtom = atomWithStorage<string | null>(
13
"selectedFeedUri",
···
36
"slingshotURL",
37
defaultslingshotURL
38
);
39
export const defaultImgCDN = "cdn.bsky.app";
40
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
41
export const defaultVideoCDN = "video.bsky.app";
···
56
| { kind: "quote"; subject: string };
57
export const composerAtom = atom<ComposerState>({ kind: "closed" });
58
59
+
//export const agentAtom = atom<Agent | null>(null);
60
+
//export const authedAtom = atom<boolean>(false);
61
62
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
63
const value = useAtomValue(atom);
-80
src/utils/useQuery.ts
-80
src/utils/useQuery.ts
···
565
});
566
}
567
568
-
export const ATURI_PAGE_LIMIT = 100;
569
-
570
-
export interface AturiDirectoryAturisItem {
571
-
uri: string;
572
-
cid: string;
573
-
rkey: string;
574
-
}
575
-
576
-
export type AturiDirectoryAturis = AturiDirectoryAturisItem[];
577
-
578
-
export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) {
579
-
return queryOptions({
580
-
// A unique key for this query, including all parameters that affect the data.
581
-
queryKey: ["aturiList", did, collection, { reverse }],
582
-
583
-
// The function that fetches the data.
584
-
queryFn: async ({ pageParam }: QueryFunctionContext) => {
585
-
const cursor = pageParam as string | undefined;
586
-
587
-
// Use URLSearchParams for safe and clean URL construction.
588
-
const params = new URLSearchParams({
589
-
did,
590
-
collection,
591
-
});
592
-
593
-
if (cursor) {
594
-
params.set("cursor", cursor);
595
-
}
596
-
597
-
// Add the reverse parameter if it's true
598
-
if (reverse) {
599
-
params.set("reverse", "true");
600
-
}
601
-
602
-
const url = `https://${aturilistservice}/aturis?${params.toString()}`;
603
-
604
-
const res = await fetch(url);
605
-
if (!res.ok) {
606
-
// You can add more specific error handling here
607
-
throw new Error(`Failed to fetch AT-URI list for ${did}`);
608
-
}
609
-
610
-
return res.json() as Promise<AturiDirectoryAturis>;
611
-
},
612
-
});
613
-
}
614
-
615
-
export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) {
616
-
// We only enable the query if both `did` and `collection` are provided.
617
-
const isEnabled = !!did && !!collection;
618
-
619
-
const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse);
620
-
621
-
return useInfiniteQuery({
622
-
queryKey,
623
-
queryFn,
624
-
initialPageParam: undefined as never, // ???? what is this shit
625
-
626
-
// @ts-expect-error i wouldve used as null | undefined, anyways
627
-
getNextPageParam: (lastPage: AturiDirectoryAturis) => {
628
-
// If the last page returned no records, we're at the end.
629
-
if (!lastPage || lastPage.length === 0) {
630
-
return undefined;
631
-
}
632
-
633
-
// If the number of records is less than our page limit, it must be the last page.
634
-
if (lastPage.length < ATURI_PAGE_LIMIT) {
635
-
return undefined;
636
-
}
637
-
638
-
// The cursor for the next page is the `rkey` of the last item we received.
639
-
const lastItem = lastPage[lastPage.length - 1];
640
-
return lastItem.rkey;
641
-
},
642
-
643
-
enabled: isEnabled,
644
-
});
645
-
}
646
-
647
-
648
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
649
650
export function constructInfiniteFeedSkeletonQuery(options: {