+164
-117
src/components/Composer.tsx
+164
-117
src/components/Composer.tsx
···
8
8
import { useQueryPost } from "~/utils/useQuery";
9
9
10
10
import { ProfileThing } from "./Login";
11
+
import { Button } from "./radix-m3-rd/Button";
11
12
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
13
13
14
const MAX_POST_LENGTH = 300;
14
15
15
16
export function Composer() {
16
17
const [composerState, setComposerState] = useAtom(composerAtom);
18
+
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
17
19
const { agent } = useAuth();
18
20
19
21
const [postText, setPostText] = useState("");
···
112
114
setPosting(false);
113
115
}
114
116
}
115
-
// if (composerState.kind === "closed") {
116
-
// return null;
117
-
// }
118
117
119
118
const getPlaceholder = () => {
120
119
switch (composerState.kind) {
···
132
131
const isPostButtonDisabled =
133
132
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
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
+
135
148
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" />
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" />
144
158
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"
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}
164
168
>
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>
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>
170
185
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>
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>
185
200
</div>
186
-
</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
+
)}
187
230
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}
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
208
242
/>
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
-
)}
243
+
</div>
214
244
</div>
215
-
)}
216
245
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>
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
+
)}
230
270
</div>
271
+
)}
272
+
</div>
273
+
</Dialog.Content>
274
+
</Dialog.Portal>
275
+
</Dialog.Root>
231
276
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
-
)}
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" />
250
282
251
-
{postError && (
252
-
<div className="text-red-500 text-sm my-2 text-center">
253
-
{postError}
254
-
</div>
255
-
)}
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>
256
303
</div>
257
-
)}
258
-
</div>
259
-
</Dialog.Content>
260
-
</Dialog.Portal>
261
-
</Dialog.Root>
304
+
</div>
305
+
</Dialog.Content>
306
+
</Dialog.Portal>
307
+
</Dialog.Root>
308
+
</>
262
309
);
263
310
}
264
311
+5
-3
src/components/Login.tsx
+5
-3
src/components/Login.tsx
···
7
7
import { imgCDNAtom } from "~/utils/atoms";
8
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
9
10
+
import { Button } from "./radix-m3-rd/Button";
11
+
10
12
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
13
export default function Login({
12
14
compact = false,
···
49
51
You are logged in!
50
52
</p>
51
53
<ProfileThing agent={agent} large />
52
-
<button
54
+
<Button
53
55
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"
56
+
className="mt-4"
55
57
>
56
58
Log out
57
-
</button>
59
+
</Button>
58
60
</div>
59
61
</div>
60
62
);
+2
src/components/UniversalPostRenderer.tsx
+2
src/components/UniversalPostRenderer.tsx
···
1557
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
1558
/>
1559
1559
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
+
<div className=" flex flex-col justify-start">
1560
1561
<FollowButton targetdidorhandle={post.author.did} />
1562
+
</div>
1561
1563
</div>
1562
1564
</div>
1563
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
1
import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
4
2
import {
5
3
type OAuthSession,
···
7
5
TokenRefreshError,
8
6
TokenRevokedError,
9
7
} from "@atproto/oauth-client-browser";
8
+
import { useAtom } from "jotai";
10
9
import React, {
11
10
createContext,
12
11
use,
···
15
14
useState,
16
15
} from "react";
17
16
18
-
import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
19
+
import { oauthClient } from "../utils/oauthClient";
19
20
20
-
// Define the unified status and authentication method
21
21
type AuthStatus = "loading" | "signedIn" | "signedOut";
22
22
type AuthMethod = "password" | "oauth" | null;
23
23
24
24
interface AuthContextValue {
25
-
agent: Agent | null; // The agent is typed as the base class `Agent`
25
+
agent: Agent | null;
26
26
status: AuthStatus;
27
27
authMethod: AuthMethod;
28
28
loginWithPassword: (
···
41
41
}: {
42
42
children: React.ReactNode;
43
43
}) => {
44
-
// The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances.
45
44
const [agent, setAgent] = useState<Agent | null>(null);
46
45
const [status, setStatus] = useState<AuthStatus>("loading");
47
46
const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
48
47
const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
48
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
49
49
50
-
// Unified Initialization Logic
51
50
const initialize = useCallback(async () => {
52
-
// --- 1. Try OAuth initialization first ---
53
51
try {
54
52
const oauthResult = await oauthClient.init();
55
53
if (oauthResult) {
56
54
// /*mass comment*/ console.log("OAuth session restored.");
57
-
const apiAgent = new Agent(oauthResult.session); // Standard Agent
55
+
const apiAgent = new Agent(oauthResult.session);
58
56
setAgent(apiAgent);
59
57
setOauthSession(oauthResult.session);
60
58
setAuthMethod("oauth");
61
59
setStatus("signedIn");
62
-
return; // Success
60
+
setQuickAuth(apiAgent?.did || null);
61
+
return;
63
62
}
64
63
} catch (e) {
65
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
+
}
66
69
}
67
70
68
-
// --- 2. If no OAuth, try password-based session using AtpAgent ---
69
71
try {
70
72
const service = localStorage.getItem("service");
71
73
const sessionString = localStorage.getItem("sess");
72
74
73
75
if (service && sessionString) {
74
76
// /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
75
-
// Use the original, working AtpAgent logic
76
77
const apiAgent = new AtpAgent({ service });
77
78
const session: AtpSessionData = JSON.parse(sessionString);
78
79
await apiAgent.resumeSession(session);
79
80
80
81
// /*mass comment*/ console.log("Password-based session resumed successfully.");
81
-
setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent
82
+
setAgent(apiAgent);
82
83
setAuthMethod("password");
83
84
setStatus("signedIn");
84
-
return; // Success
85
+
setQuickAuth(apiAgent?.did || null);
86
+
return;
85
87
}
86
88
} catch (e) {
87
89
console.error("Failed to resume password-based session.", e);
···
89
91
localStorage.removeItem("service");
90
92
}
91
93
92
-
// --- 3. If neither worked, user is signed out ---
93
94
// /*mass comment*/ console.log("No active session found.");
94
95
setStatus("signedOut");
95
96
setAgent(null);
96
97
setAuthMethod(null);
97
-
}, []);
98
+
// do we want to null it here?
99
+
setQuickAuth(null);
100
+
}, [quickAuth, setQuickAuth]);
98
101
99
102
useEffect(() => {
100
103
const handleOAuthSessionDeleted = (
···
105
108
setOauthSession(null);
106
109
setAuthMethod(null);
107
110
setStatus("signedOut");
111
+
setQuickAuth(null);
108
112
};
109
113
110
114
oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
···
113
117
return () => {
114
118
oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
115
119
};
116
-
}, [initialize]);
120
+
}, [initialize, setQuickAuth]);
117
121
118
-
// --- Login Methods ---
119
122
const loginWithPassword = async (
120
123
user: string,
121
124
password: string,
···
125
128
setStatus("loading");
126
129
try {
127
130
let sessionData: AtpSessionData | undefined;
128
-
// Use the AtpAgent for its simple login and session persistence
129
131
const apiAgent = new AtpAgent({
130
132
service,
131
133
persistSession: (_evt, sess) => {
···
137
139
if (sessionData) {
138
140
localStorage.setItem("service", service);
139
141
localStorage.setItem("sess", JSON.stringify(sessionData));
140
-
setAgent(apiAgent); // Store the AtpAgent instance in our state
142
+
setAgent(apiAgent);
141
143
setAuthMethod("password");
142
144
setStatus("signedIn");
145
+
setQuickAuth(apiAgent?.did || null);
143
146
// /*mass comment*/ console.log("Successfully logged in with password.");
144
147
} else {
145
148
throw new Error("Session data not persisted after login.");
···
147
150
} catch (e) {
148
151
console.error("Password login failed:", e);
149
152
setStatus("signedOut");
153
+
setQuickAuth(null);
150
154
throw e;
151
155
}
152
156
};
···
161
165
}
162
166
}, [status]);
163
167
164
-
// --- Unified Logout ---
165
168
const logout = useCallback(async () => {
166
169
if (status !== "signedIn" || !agent) return;
167
170
setStatus("loading");
···
173
176
} else if (authMethod === "password") {
174
177
localStorage.removeItem("service");
175
178
localStorage.removeItem("sess");
176
-
// AtpAgent has its own logout methods
177
179
await (agent as AtpAgent).com.atproto.server.deleteSession();
178
180
// /*mass comment*/ console.log("Password-based session deleted.");
179
181
}
···
184
186
setAuthMethod(null);
185
187
setOauthSession(null);
186
188
setStatus("signedOut");
189
+
setQuickAuth(null);
187
190
}
188
-
}, [status, authMethod, agent, oauthSession]);
191
+
}, [status, agent, authMethod, oauthSession, setQuickAuth]);
189
192
190
193
return (
191
194
<AuthContext
+39
-40
src/routes/index.tsx
+39
-40
src/routes/index.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
import { useAtom } from "jotai";
3
3
import * as React from "react";
4
-
import { useEffect, useLayoutEffect } from "react";
4
+
import { useLayoutEffect, useState } from "react";
5
5
6
6
import { Header } from "~/components/Header";
7
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
9
import {
10
-
agentAtom,
11
-
authedAtom,
12
10
feedScrollPositionsAtom,
13
11
isAtTopAtom,
12
+
quickAuthAtom,
14
13
selectedFeedUriAtom,
15
-
store,
16
14
} from "~/utils/atoms";
17
15
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
18
16
import {
···
107
105
} = useAuth();
108
106
const authed = !!agent?.did;
109
107
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]);
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]);
126
125
127
126
//const { get, set } = usePersistentStore();
128
127
// const [feed, setFeed] = React.useState<any[]>([]);
···
162
161
163
162
// const savedFeeds = savedFeedsPref?.items || [];
164
163
165
-
const identityresultmaybe = useQueryIdentity(agent?.did);
164
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
165
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
166
+
167
+
const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined);
166
168
const identity = identityresultmaybe?.data;
167
169
168
170
const prefsresultmaybe = useQueryPreferences({
169
-
agent: agent ?? undefined,
170
-
pdsUrl: identity?.pds,
171
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
172
+
pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined,
171
173
});
172
174
const prefs = prefsresultmaybe?.data;
173
175
···
178
180
return savedFeedsPref?.items || [];
179
181
}, [prefs]);
180
182
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);
183
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
184
+
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed);
186
185
const selectedFeed = agent?.did
187
186
? persistentSelectedFeed
188
187
: unauthedSelectedFeed;
···
306
305
}, [scrollPositions]);
307
306
308
307
useLayoutEffect(() => {
308
+
if (isAuthRestoring) return;
309
309
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
310
310
311
311
window.scrollTo({ top: savedPosition, behavior: "instant" });
312
312
// eslint-disable-next-line react-hooks/exhaustive-deps
313
-
}, [selectedFeed]);
313
+
}, [selectedFeed, isAuthRestoring]);
314
314
315
315
useLayoutEffect(() => {
316
-
if (!selectedFeed) return;
316
+
if (!selectedFeed || isAuthRestoring) return;
317
317
318
318
const handleScroll = () => {
319
319
scrollPositionsRef.current = {
···
328
328
329
329
setScrollPositions(scrollPositionsRef.current);
330
330
};
331
-
}, [selectedFeed, setScrollPositions]);
331
+
}, [isAuthRestoring, selectedFeed, setScrollPositions]);
332
332
333
-
const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined);
334
-
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
333
+
const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined);
334
+
const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined;
335
335
336
336
// const {
337
337
// data: feedData,
···
347
347
348
348
// const feed = feedData?.feed || [];
349
349
350
-
const isReadyForAuthedFeed =
351
-
authed && agent && identity?.pds && feedServiceDid;
352
-
const isReadyForUnauthedFeed = !authed && selectedFeed;
350
+
const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
351
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed;
353
352
354
353
355
354
const [isAtTop] = useAtom(isAtTopAtom);
···
358
357
<div
359
358
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
360
359
>
361
-
{savedFeeds.length > 0 ? (
360
+
{!isAuthRestoring && savedFeeds.length > 0 ? (
362
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`}>
363
362
{savedFeeds.map((item: any, idx: number) => {
364
363
const label = item.value.split("/").pop() || item.value;
···
410
409
/>
411
410
))} */}
412
411
413
-
{authed && (!identity?.pds || !feedServiceDid) && (
412
+
{isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && (
414
413
<div className="p-4 text-center text-gray-500">
415
414
Preparing your feed...
416
415
</div>
417
416
)}
418
417
419
-
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
418
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
420
419
<InfiniteCustomFeed
421
420
key={selectedFeed!}
422
421
feedUri={selectedFeed!}
···
425
424
/>
426
425
) : (
427
426
<div className="p-4 text-center text-gray-500">
428
-
Select a feed to get started.
427
+
Loading.......
429
428
</div>
430
429
)}
431
430
{/* {false && restoringScrollPosition && (
+15
-25
src/routes/profile.$did/index.tsx
+15
-25
src/routes/profile.$did/index.tsx
···
5
5
import React, { type ReactNode, useEffect, useState } from "react";
6
6
7
7
import { Header } from "~/components/Header";
8
+
import { Button } from "~/components/radix-m3-rd/Button";
8
9
import {
9
10
renderTextWithFacets,
10
11
UniversalPostRendererATURILoader,
11
12
} from "~/components/UniversalPostRenderer";
12
13
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
-
import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms";
14
+
import { imgCDNAtom } from "~/utils/atoms";
14
15
import {
15
16
toggleFollow,
16
17
useGetFollowState,
17
18
useGetOneToOneState,
18
19
} from "~/utils/followState";
19
20
import {
20
-
useInfiniteQueryAturiList,
21
+
useInfiniteQueryAuthorFeed,
21
22
useQueryIdentity,
22
23
useQueryProfile,
23
24
} from "~/utils/useQuery";
···
29
30
function ProfileComponent() {
30
31
// booo bad this is not always the did it might be a handle, use identity.did instead
31
32
const { did } = Route.useParams();
32
-
//const navigate = useNavigate();
33
+
const navigate = useNavigate();
33
34
const queryClient = useQueryClient();
34
35
const {
35
36
data: identity,
···
39
40
40
41
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
42
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
-
//const pdsUrl = identity?.pds;
43
+
const pdsUrl = identity?.pds;
43
44
44
45
const profileUri = resolvedDid
45
46
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
47
48
const { data: profileRecord } = useQueryProfile(profileUri);
48
49
const profile = profileRecord?.value;
49
50
50
-
const [aturilistservice] = useAtom(aturiListServiceAtom);
51
-
52
51
const {
53
52
data: postsData,
54
53
fetchNextPage,
55
54
hasNextPage,
56
55
isFetchingNextPage,
57
56
isLoading: arePostsLoading,
58
-
} = useInfiniteQueryAturiList({
59
-
aturilistservice: aturilistservice,
60
-
did: resolvedDid,
61
-
collection: "app.bsky.feed.post",
62
-
reverse: true
63
-
});
57
+
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
64
58
65
59
React.useEffect(() => {
66
60
if (postsData) {
67
61
postsData.pages.forEach((page) => {
68
-
page.forEach((record) => {
62
+
page.records.forEach((record) => {
69
63
if (!queryClient.getQueryData(["post", record.uri])) {
70
64
queryClient.setQueryData(["post", record.uri], record);
71
65
}
···
75
69
}, [postsData, queryClient]);
76
70
77
71
const posts = React.useMemo(
78
-
() => postsData?.pages.flatMap((page) => page) ?? [],
72
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
79
73
[postsData]
80
74
);
81
75
···
177
171
also save it persistently
178
172
*/}
179
173
<FollowButton targetdidorhandle={did} />
180
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
174
+
<Button className="rounded-full" variant={"secondary"}>
181
175
... {/* todo: icon */}
182
-
</button>
176
+
</Button>
183
177
</div>
184
178
185
179
{/* Info Card */}
···
255
249
{identity?.did !== agent?.did ? (
256
250
<>
257
251
{!(followRecords?.length && followRecords?.length > 0) ? (
258
-
<button
252
+
<Button
259
253
onClick={(e) => {
260
254
e.stopPropagation();
261
255
toggleFollow({
···
265
259
queryClient: queryClient,
266
260
});
267
261
}}
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
262
>
270
263
Follow
271
-
</button>
264
+
</Button>
272
265
) : (
273
-
<button
266
+
<Button
274
267
onClick={(e) => {
275
268
e.stopPropagation();
276
269
toggleFollow({
···
280
273
queryClient: queryClient,
281
274
});
282
275
}}
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
276
>
285
277
Unfollow
286
-
</button>
278
+
</Button>
287
279
)}
288
280
</>
289
281
) : (
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>
282
+
<Button variant={"secondary"}>Edit Profile</Button>
293
283
)}
294
284
</>
295
285
);
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
···
85
85
e.stopPropagation();
86
86
e.nativeEvent.stopImmediatePropagation();
87
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"
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
89
>
90
90
<ProfilePostComponent
91
91
key={`/profile/${did}/post/${rkey}`}
-8
src/routes/settings.tsx
-8
src/routes/settings.tsx
···
5
5
import { Header } from "~/components/Header";
6
6
import Login from "~/components/Login";
7
7
import {
8
-
aturiListServiceAtom,
9
8
constellationURLAtom,
10
-
defaultaturilistservice,
11
9
defaultconstellationURL,
12
10
defaulthue,
13
11
defaultImgCDN,
···
53
51
title={"Slingshot"}
54
52
description={"Customize the Slingshot instance to be used by Red Dwarf"}
55
53
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
54
/>
63
55
<TextInputSetting
64
56
atom={imgCDNAtom}
+6
-2
src/styles/app.css
+6
-2
src/styles/app.css
···
52
52
}
53
53
}
54
54
55
+
.gutter{
56
+
scrollbar-gutter: stable both-edges;
57
+
}
58
+
55
59
@media (width >= 64rem /* 1024px */) {
56
60
html:not(:has(.disablegutter)),
57
61
body:not(:has(.disablegutter)) {
58
62
scrollbar-gutter: stable both-edges !important;
59
63
}
60
-
html:has(.disablegutter),
61
-
body:has(.disablegutter) {
64
+
html:has(.disablescroll),
65
+
body:has(.disablescroll) {
62
66
scrollbar-width: none;
63
67
overflow-y: hidden;
64
68
}
+7
-8
src/utils/atoms.ts
+7
-8
src/utils/atoms.ts
···
1
-
import type Agent from "@atproto/api";
2
1
import { atom, createStore, useAtomValue } from "jotai";
3
2
import { atomWithStorage } from "jotai/utils";
4
3
import { useEffect } from "react";
5
4
6
5
export const store = createStore();
6
+
7
+
export const quickAuthAtom = atomWithStorage<string | null>(
8
+
"quickAuth",
9
+
null
10
+
);
7
11
8
12
export const selectedFeedUriAtom = atomWithStorage<string | null>(
9
13
"selectedFeedUri",
···
32
36
"slingshotURL",
33
37
defaultslingshotURL
34
38
);
35
-
export const defaultaturilistservice = "aturilistservice.reddwarf.app";
36
-
export const aturiListServiceAtom = atomWithStorage<string>(
37
-
"aturilistservice",
38
-
defaultaturilistservice
39
-
);
40
39
export const defaultImgCDN = "cdn.bsky.app";
41
40
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
42
41
export const defaultVideoCDN = "video.bsky.app";
···
57
56
| { kind: "quote"; subject: string };
58
57
export const composerAtom = atom<ComposerState>({ kind: "closed" });
59
58
60
-
export const agentAtom = atom<Agent | null>(null);
61
-
export const authedAtom = atom<boolean>(false);
59
+
//export const agentAtom = atom<Agent | null>(null);
60
+
//export const authedAtom = atom<boolean>(false);
62
61
63
62
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
64
63
const value = useAtomValue(atom);
-80
src/utils/useQuery.ts
-80
src/utils/useQuery.ts
···
565
565
});
566
566
}
567
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
568
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
649
569
650
570
export function constructInfiniteFeedSkeletonQuery(options: {