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
constellation-based follow state
rimar1337
4 months ago
404a7649
d1602982
+210
-18
4 changed files
expand all
collapse all
unified
split
src
components
Login.tsx
routes
profile.$did
index.tsx
utils
followState.ts
useQuery.ts
+19
-1
src/components/Login.tsx
···
154
154
const OAuthForm = () => {
155
155
const { loginWithOAuth } = useAuth();
156
156
const [handle, setHandle] = useState("");
157
157
-
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (handle.trim()) loginWithOAuth(handle); };
157
157
+
158
158
+
useEffect(() => {
159
159
+
const lastHandle = localStorage.getItem("lastHandle");
160
160
+
if (lastHandle) setHandle(lastHandle);
161
161
+
}, []);
162
162
+
163
163
+
const handleSubmit = (e: React.FormEvent) => {
164
164
+
e.preventDefault();
165
165
+
if (handle.trim()) {
166
166
+
localStorage.setItem("lastHandle", handle);
167
167
+
loginWithOAuth(handle);
168
168
+
}
169
169
+
};
158
170
return (
159
171
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
160
172
<p className="text-xs text-gray-500 dark:text-gray-400">Sign in with AT. Your password is never shared.</p>
···
171
183
const [serviceURL, setServiceURL] = useState("bsky.social");
172
184
const [error, setError] = useState<string | null>(null);
173
185
186
186
+
useEffect(() => {
187
187
+
const lastHandle = localStorage.getItem("lastHandle");
188
188
+
if (lastHandle) setUser(lastHandle);
189
189
+
}, []);
190
190
+
174
191
const handleSubmit = async (e: React.FormEvent) => {
175
192
e.preventDefault();
176
193
setError(null);
177
194
try {
195
195
+
localStorage.setItem("lastHandle", user);
178
196
await loginWithPassword(user, password, `https://${serviceURL}`);
179
197
} catch (err) {
180
198
setError("Login failed. Check your handle and App Password.");
+43
-8
src/routes/profile.$did/index.tsx
···
7
7
useQueryIdentity,
8
8
useQueryProfile,
9
9
useInfiniteQueryAuthorFeed,
10
10
+
useQueryConstellation,
11
11
+
type linksRecordsResponse,
10
12
} from "~/utils/useQuery";
13
13
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
14
14
+
import { AtUri } from "@atproto/api";
15
15
+
import { TID } from "@atproto/common-web";
16
16
+
import { toggleFollow, useGetFollowState } from "~/utils/followState";
11
17
12
18
export const Route = createFileRoute("/profile/$did/")({
13
19
component: ProfileComponent,
14
20
});
15
21
16
22
function ProfileComponent() {
23
23
+
// booo bad this is not always the did it might be a handle, use identity.did instead
17
24
const { did } = Route.useParams();
18
25
const queryClient = useQueryClient();
19
19
-
26
26
+
const { agent } = useAuth();
20
27
const {
21
28
data: identity,
22
29
isLoading: isIdentityLoading,
23
30
error: identityError,
24
31
} = useQueryIdentity(did);
32
32
+
33
33
+
const followRecords = useGetFollowState({
34
34
+
target: identity?.did || did,
35
35
+
user: agent?.did,
36
36
+
});
25
37
26
38
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
27
39
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
···
141
153
also delay the backfill to be on demand because it would be pretty intense
142
154
also save it persistently
143
155
*/}
144
144
-
{true ? (
156
156
+
{identity?.did !== agent?.did ? (
145
157
<>
146
146
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
147
147
-
Follow
148
148
-
</button>
149
149
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
150
150
-
Unfollow
151
151
-
</button>
158
158
+
{!(followRecords?.length && followRecords?.length > 0) ? (
159
159
+
<button
160
160
+
onClick={() =>
161
161
+
toggleFollow({
162
162
+
agent: agent || undefined,
163
163
+
targetDid: identity?.did,
164
164
+
followRecords: followRecords,
165
165
+
queryClient: queryClient,
166
166
+
})
167
167
+
}
168
168
+
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
169
169
+
>
170
170
+
Follow
171
171
+
</button>
172
172
+
) : (
173
173
+
<button
174
174
+
onClick={() =>
175
175
+
toggleFollow({
176
176
+
agent: agent || undefined,
177
177
+
targetDid: identity?.did,
178
178
+
followRecords: followRecords,
179
179
+
queryClient: queryClient,
180
180
+
})
181
181
+
}
182
182
+
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
183
183
+
>
184
184
+
Unfollow
185
185
+
</button>
186
186
+
)}
152
187
</>
153
188
) : (
154
189
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
+129
src/utils/followState.ts
···
1
1
+
import { AtUri, type Agent } from "@atproto/api";
2
2
+
import { useQueryConstellation, type linksRecordsResponse } from "./useQuery";
3
3
+
import type { QueryClient } from "@tanstack/react-query";
4
4
+
import { TID } from "@atproto/common-web";
5
5
+
6
6
+
export function useGetFollowState({
7
7
+
target,
8
8
+
user,
9
9
+
}: {
10
10
+
target: string;
11
11
+
user?: string;
12
12
+
}): string[] | undefined {
13
13
+
const { data: followData } = useQueryConstellation(
14
14
+
user
15
15
+
? {
16
16
+
method: "/links",
17
17
+
target: target,
18
18
+
// @ts-expect-error overloading sucks so much
19
19
+
collection: "app.bsky.graph.follow",
20
20
+
path: ".subject",
21
21
+
dids: [user],
22
22
+
}
23
23
+
: { method: "undefined", target: "whatever" }
24
24
+
// overloading sucks so much
25
25
+
) as { data: linksRecordsResponse | undefined };
26
26
+
const follows = followData?.linking_records.slice(0, 50) ?? [];
27
27
+
28
28
+
if (follows.length > 0) {
29
29
+
return follows.map((linksRecord) => {
30
30
+
return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`;
31
31
+
});
32
32
+
}
33
33
+
34
34
+
return undefined;
35
35
+
}
36
36
+
37
37
+
export function toggleFollow({
38
38
+
agent,
39
39
+
targetDid,
40
40
+
followRecords,
41
41
+
queryClient,
42
42
+
}: {
43
43
+
agent?: Agent;
44
44
+
targetDid?: string;
45
45
+
followRecords: undefined | string[];
46
46
+
queryClient: QueryClient;
47
47
+
}) {
48
48
+
if (!agent?.did || !targetDid) return;
49
49
+
50
50
+
const queryKey = [
51
51
+
"constellation",
52
52
+
"/links",
53
53
+
targetDid,
54
54
+
"app.bsky.graph.follow",
55
55
+
".subject",
56
56
+
undefined,
57
57
+
[agent.did],
58
58
+
] as const;
59
59
+
60
60
+
const updateCache = (
61
61
+
updater: (
62
62
+
oldData: linksRecordsResponse | undefined
63
63
+
) => linksRecordsResponse | undefined
64
64
+
) => {
65
65
+
queryClient.setQueryData(
66
66
+
queryKey,
67
67
+
(oldData: linksRecordsResponse | undefined) => updater(oldData)
68
68
+
);
69
69
+
};
70
70
+
71
71
+
if (typeof followRecords === "undefined") {
72
72
+
const newRecord = {
73
73
+
repo: agent.did,
74
74
+
collection: "app.bsky.graph.follow",
75
75
+
rkey: TID.next().toString(),
76
76
+
record: {
77
77
+
$type: "app.bsky.graph.follow",
78
78
+
subject: targetDid,
79
79
+
createdAt: new Date().toISOString(),
80
80
+
},
81
81
+
};
82
82
+
83
83
+
updateCache((old) => {
84
84
+
const newLinkingRecords = [newRecord, ...(old?.linking_records ?? [])];
85
85
+
return {
86
86
+
...old,
87
87
+
linking_records: newLinkingRecords,
88
88
+
} as linksRecordsResponse;
89
89
+
});
90
90
+
91
91
+
agent.com.atproto.repo.createRecord(newRecord).catch((err) => {
92
92
+
console.error("Follow failed, reverting cache:", err);
93
93
+
// rollback cache
94
94
+
updateCache((old) => {
95
95
+
return {
96
96
+
...old,
97
97
+
linking_records:
98
98
+
old?.linking_records.filter((r) => r.rkey !== newRecord.rkey) ?? [],
99
99
+
} as linksRecordsResponse;
100
100
+
});
101
101
+
});
102
102
+
103
103
+
return;
104
104
+
}
105
105
+
106
106
+
followRecords.forEach((followRecord) => {
107
107
+
const aturi = new AtUri(followRecord);
108
108
+
agent.com.atproto.repo
109
109
+
.deleteRecord({
110
110
+
repo: agent.did!,
111
111
+
collection: "app.bsky.graph.follow",
112
112
+
rkey: aturi.rkey,
113
113
+
})
114
114
+
.catch(console.error);
115
115
+
});
116
116
+
117
117
+
updateCache((old) => {
118
118
+
if (!old?.linking_records) return old;
119
119
+
return {
120
120
+
...old,
121
121
+
linking_records: old.linking_records.filter(
122
122
+
(rec) =>
123
123
+
!followRecords.includes(
124
124
+
`at://${rec.did}/${rec.collection}/${rec.rkey}`
125
125
+
)
126
126
+
),
127
127
+
};
128
128
+
});
129
129
+
}
+19
-9
src/utils/useQuery.ts
···
187
187
| "/links/distinct-dids"
188
188
| "/links/count"
189
189
| "/links/count/distinct-dids"
190
190
-
| "/links/all",
190
190
+
| "/links/all"
191
191
+
| "undefined",
191
192
target: string,
192
193
collection?: string,
193
194
path?: string,
194
194
-
cursor?: string
195
195
+
cursor?: string,
196
196
+
dids?: string[]
195
197
}
196
198
) {
197
199
// : QueryOptions<
···
203
205
// Error
204
206
// >
205
207
return queryOptions({
206
206
-
queryKey: ["post", query?.method, query?.target, query?.collection, query?.path, query?.cursor] as const,
208
208
+
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
207
209
queryFn: async () => {
208
208
-
if (!query) return undefined as undefined
210
210
+
if (!query || query.method === "undefined") return undefined as undefined
209
211
const method = query.method
210
212
const target = query.target
211
213
const collection = query?.collection
212
214
const path = query?.path
213
215
const cursor = query.cursor
216
216
+
const dids = query?.dids
214
217
const res = await fetch(
215
215
-
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
218
218
+
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
216
219
);
217
220
if (!res.ok) throw new Error("Failed to fetch post");
218
221
try {
···
235
238
}
236
239
},
237
240
// enforce short lifespan
238
238
-
staleTime: 5 * 60 * 1000, // 5 minutes
239
239
-
gcTime: 5 * 60 * 1000,
241
241
+
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
242
242
+
gcTime: /*0//*/5 * 60 * 1000,
240
243
});
241
244
}
242
245
export function useQueryConstellation(query: {
···
245
248
collection: string;
246
249
path: string;
247
250
cursor?: string;
251
251
+
dids?: string[];
248
252
}): UseQueryResult<linksRecordsResponse, Error>;
249
253
export function useQueryConstellation(query: {
250
254
method: "/links/distinct-dids";
···
272
276
target: string;
273
277
}): UseQueryResult<linksAllResponse, Error>;
274
278
export function useQueryConstellation(): undefined;
279
279
+
export function useQueryConstellation(query: {
280
280
+
method: "undefined";
281
281
+
target: string;
282
282
+
}): undefined;
275
283
export function useQueryConstellation(query?: {
276
284
method:
277
285
| "/links"
278
286
| "/links/distinct-dids"
279
287
| "/links/count"
280
288
| "/links/count/distinct-dids"
281
281
-
| "/links/all";
289
289
+
| "/links/all"
290
290
+
| "undefined";
282
291
target: string;
283
292
collection?: string;
284
293
path?: string;
285
294
cursor?: string;
295
295
+
dids?: string[];
286
296
}):
287
297
| UseQueryResult<
288
298
| linksRecordsResponse
···
304
314
collection: string;
305
315
rkey: string;
306
316
};
307
307
-
type linksRecordsResponse = {
317
317
+
export type linksRecordsResponse = {
308
318
total: string;
309
319
linking_records: linksRecord[];
310
320
cursor?: string;