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
Polls refresh state button
whey.party
1 month ago
79482f07
e4567e6a
+305
-132
4 changed files
expand all
collapse all
unified
split
src
components
UniversalPostRenderer.tsx
providers
PollMutationQueueProvider.tsx
utils
followState.ts
useQuery.ts
+108
-62
src/components/UniversalPostRenderer.tsx
···
408
408
setReplies(
409
409
links
410
410
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
411
411
-
?.records || 0
411
411
+
?.records || 0
412
412
: null,
413
413
);
414
414
}, [links]);
···
456
456
457
457
const replyAturis = repliesData
458
458
? repliesData.pages.flatMap((page) =>
459
459
-
page
460
460
-
? page.linking_records.map((record) => {
461
461
-
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
462
462
-
return aturi;
463
463
-
})
464
464
-
: [],
465
465
-
)
459
459
+
page
460
460
+
? page.linking_records.map((record) => {
461
461
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
462
462
+
return aturi;
463
463
+
})
464
464
+
: [],
465
465
+
)
466
466
: [];
467
467
468
468
//const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
···
622
622
opacity: 0.5,
623
623
}}
624
624
className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
625
625
-
//className="border-gray-400 dark:border-gray-500"
625
625
+
//className="border-gray-400 dark:border-gray-500"
626
626
/>
627
627
</div>
628
628
···
768
768
const isQuotewithImages =
769
769
isquotewithmedia &&
770
770
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
771
-
"app.bsky.embed.images";
771
771
+
"app.bsky.embed.images";
772
772
const isQuotewithVideo =
773
773
isquotewithmedia &&
774
774
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
775
775
-
"app.bsky.embed.video";
775
775
+
"app.bsky.embed.video";
776
776
777
777
const hasMedia =
778
778
hasEmbed &&
···
1258
1258
import defaultpfp from "~/../public/favicon.png";
1259
1259
import {
1260
1260
usePollData,
1261
1261
+
usePollMutationQueue,
1261
1262
} from "~/providers/PollMutationQueueProvider";
1262
1263
import { useAuth } from "~/providers/UnifiedAuthProvider";
1263
1264
import { renderSnack } from "~/routes/__root";
···
1494
1495
1495
1496
const tags = unfediwafrnTags
1496
1497
? unfediwafrnTags
1497
1497
-
.split("\n")
1498
1498
-
.map((t) => t.trim())
1499
1499
-
.filter(Boolean)
1498
1498
+
.split("\n")
1499
1499
+
.map((t) => t.trim())
1500
1500
+
.filter(Boolean)
1500
1501
: undefined;
1501
1502
1502
1503
const links = tags
1503
1504
? tags
1504
1504
-
.map((tag) => {
1505
1505
-
const encoded = encodeURIComponent(tag);
1506
1506
-
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1507
1507
-
})
1508
1508
-
.join("<br>")
1505
1505
+
.map((tag) => {
1506
1506
+
const encoded = encodeURIComponent(tag);
1507
1507
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1508
1508
+
})
1509
1509
+
.join("<br>")
1509
1510
: "";
1510
1511
1511
1512
const unfediwafrn = unfediwafrnPartial
···
1518
1519
1519
1520
/* fuck you */
1520
1521
const isMainItem = false;
1521
1521
-
const setMainItem = (any: any) => { };
1522
1522
+
const setMainItem = (any: any) => {};
1522
1523
// eslint-disable-next-line react-hooks/refs
1523
1524
//console.log("Received ref in UniversalPostRenderer:", usedref);
1524
1525
return (
···
1532
1533
: setMainItem
1533
1534
? onPostClick
1534
1535
? (e) => {
1535
1535
-
setMainItem({ post: post });
1536
1536
-
onPostClick(e);
1537
1537
-
}
1536
1536
+
setMainItem({ post: post });
1537
1537
+
onPostClick(e);
1538
1538
+
}
1538
1539
: () => {
1539
1539
-
setMainItem({ post: post });
1540
1540
-
}
1540
1540
+
setMainItem({ post: post });
1541
1541
+
}
1541
1542
: undefined
1542
1543
}
1543
1544
style={{
···
2020
2021
try {
2021
2022
await navigator.clipboard.writeText(
2022
2023
"https://bsky.app" +
2023
2023
-
"/profile/" +
2024
2024
-
post.author.handle +
2025
2025
-
"/post/" +
2026
2026
-
post.uri.split("/").pop(),
2024
2024
+
"/profile/" +
2025
2025
+
post.author.handle +
2026
2026
+
"/post/" +
2027
2027
+
post.uri.split("/").pop(),
2027
2028
);
2028
2029
renderSnack({
2029
2030
title: "Copied to clipboard!",
···
2131
2132
| AppBskyEmbedVideo.View
2132
2133
| AppBskyEmbedExternal.View
2133
2134
| AppBskyEmbedRecordWithMedia.View
2134
2134
-
| { $type: string;[k: string]: unknown };
2135
2135
+
| { $type: string; [k: string]: unknown };
2135
2136
2136
2137
enum PostEmbedViewContext {
2137
2138
ThreadHighlighted = "ThreadHighlighted",
···
2148
2149
2149
2150
function PollEmbed({ did, rkey }: { did: string; rkey: string }) {
2150
2151
const { agent } = useAuth();
2152
2152
+
const { refreshPollData } = usePollMutationQueue();
2151
2153
const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`;
2152
2154
const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri);
2153
2155
···
2159
2161
target: pollUri,
2160
2162
collection: "app.reddwarf.poll.vote.a",
2161
2163
path: ".subject.uri",
2164
2164
+
customkey: "constellation-polls",
2162
2165
});
2163
2166
2164
2167
const { data: voteCountsB } = useQueryConstellation({
···
2166
2169
target: pollUri,
2167
2170
collection: "app.reddwarf.poll.vote.b",
2168
2171
path: ".subject.uri",
2172
2172
+
customkey: "constellation-polls",
2169
2173
});
2170
2174
2171
2175
const { data: voteCountsC } = useQueryConstellation({
···
2173
2177
target: pollUri,
2174
2178
collection: "app.reddwarf.poll.vote.c",
2175
2179
path: ".subject.uri",
2180
2180
+
customkey: "constellation-polls",
2176
2181
});
2177
2182
2178
2183
const { data: voteCountsD } = useQueryConstellation({
···
2180
2185
target: pollUri,
2181
2186
collection: "app.reddwarf.poll.vote.d",
2182
2187
path: ".subject.uri",
2188
2188
+
customkey: "constellation-polls",
2183
2189
});
2184
2190
2185
2191
// Query first page of voters for Avatars
2186
2192
const { data: votersA } = useQueryConstellation({
2187
2187
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri",
2193
2193
+
method: "/links",
2194
2194
+
target: pollUri,
2195
2195
+
collection: "app.reddwarf.poll.vote.a",
2196
2196
+
path: ".subject.uri",
2197
2197
+
customkey: "constellation-polls",
2188
2198
});
2189
2199
const { data: votersB } = useQueryConstellation({
2190
2190
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri",
2200
2200
+
method: "/links",
2201
2201
+
target: pollUri,
2202
2202
+
collection: "app.reddwarf.poll.vote.b",
2203
2203
+
path: ".subject.uri",
2204
2204
+
customkey: "constellation-polls",
2191
2205
});
2192
2206
const { data: votersC } = useQueryConstellation({
2193
2193
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri",
2207
2207
+
method: "/links",
2208
2208
+
target: pollUri,
2209
2209
+
collection: "app.reddwarf.poll.vote.c",
2210
2210
+
path: ".subject.uri",
2211
2211
+
customkey: "constellation-polls",
2194
2212
});
2195
2213
const { data: votersD } = useQueryConstellation({
2196
2196
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri",
2214
2214
+
method: "/links",
2215
2215
+
target: pollUri,
2216
2216
+
collection: "app.reddwarf.poll.vote.d",
2217
2217
+
path: ".subject.uri",
2218
2218
+
customkey: "constellation-polls",
2197
2219
});
2198
2220
2199
2221
// --- 2. Prepare Data ---
···
2226
2248
pollUri,
2227
2249
pollRecord?.cid,
2228
2250
!!poll.multiple,
2229
2229
-
serverCounts
2251
2251
+
serverCounts,
2230
2252
);
2231
2253
2232
2254
// --- 4. Render ---
···
2366
2388
)}
2367
2389
{poll.multiple ? "Select one or more options" : "Select one option"}
2368
2390
</span>
2391
2391
+
2392
2392
+
{/* Refresh Button */}
2393
2393
+
<button
2394
2394
+
onClick={(e) => {
2395
2395
+
e.stopPropagation();
2396
2396
+
refreshPollData(pollUri);
2397
2397
+
}}
2398
2398
+
className="ml-auto rounded-full h-8 outline outline-gray-200 text-gray-700 dark:outline-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-3 py-1 text-[12px] flex items-center gap-1"
2399
2399
+
title="Refresh poll data"
2400
2400
+
>
2401
2401
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
2402
2402
+
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
2403
2403
+
</svg>
2404
2404
+
Refresh
2405
2405
+
</button>
2369
2406
</div>
2370
2407
2371
2408
{/* Options List with Results */}
2372
2409
<div className="space-y-3">
2373
2410
{options.map((optionText, index) => {
2374
2374
-
const optionKey = ["a", "b", "c", "d"][index] as "a" | "b" | "c" | "d";
2411
2411
+
const optionKey = ["a", "b", "c", "d"][index] as
2412
2412
+
| "a"
2413
2413
+
| "b"
2414
2414
+
| "c"
2415
2415
+
| "d";
2375
2416
const { topVoterDids } = results[optionKey];
2376
2417
const optionState = results[optionKey];
2377
2418
const hasVotedForOption = optionState.hasVoted;
2378
2378
-
const votePercentage = totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0;
2419
2419
+
const votePercentage =
2420
2420
+
totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0;
2379
2421
2380
2422
// Helper to get voters for avatars
2381
2423
const votersData = (() => {
2382
2382
-
if (optionKey === 'a') return votersA?.linking_records || [];
2383
2383
-
if (optionKey === 'b') return votersB?.linking_records || [];
2384
2384
-
if (optionKey === 'c') return votersC?.linking_records || [];
2385
2385
-
if (optionKey === 'd') return votersD?.linking_records || [];
2424
2424
+
if (optionKey === "a") return votersA?.linking_records || [];
2425
2425
+
if (optionKey === "b") return votersB?.linking_records || [];
2426
2426
+
if (optionKey === "c") return votersC?.linking_records || [];
2427
2427
+
if (optionKey === "d") return votersD?.linking_records || [];
2386
2428
return [];
2387
2429
})();
2388
2388
-
const topVoters = votersData.filter((v: any) => !!v.did).slice(0, 5);
2430
2430
+
const topVoters = votersData
2431
2431
+
.filter((v: any) => !!v.did)
2432
2432
+
.slice(0, 5);
2389
2433
2390
2434
return (
2391
2435
<div
2392
2436
key={index}
2393
2393
-
className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${!isExpired
2437
2437
+
className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${
2438
2438
+
!isExpired
2394
2439
? hasVotedForOption
2395
2440
? "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer outline-2 outline-gray-500 dark:outline-gray-400"
2396
2441
: "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer"
2397
2442
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700"
2398
2398
-
}`}
2443
2443
+
}`}
2399
2444
onClick={(e) => {
2400
2445
e.stopPropagation();
2401
2446
if (!isExpired) {
···
2423
2468
<div className="relative z-[2] flex items-center gap-2">
2424
2469
{/* Avatar circles - semi overlapping */}
2425
2470
{topVoterDids.length > 0 && (
2426
2426
-
<div className="flex -space-x-2">
2427
2427
-
{topVoterDids.map((did, idx) => (
2428
2428
-
<div
2429
2429
-
key={did}
2430
2430
-
className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200"
2431
2431
-
style={{ zIndex: 5 - idx }}
2432
2432
-
>
2433
2433
-
<PollOptionAvatar did={did} />
2434
2434
-
</div>
2435
2435
-
))}
2436
2436
-
</div>
2437
2437
-
)}
2471
2471
+
<div className="flex -space-x-2">
2472
2472
+
{topVoterDids.map((did, idx) => (
2473
2473
+
<div
2474
2474
+
key={did}
2475
2475
+
className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200"
2476
2476
+
style={{ zIndex: 5 - idx }}
2477
2477
+
>
2478
2478
+
<PollOptionAvatar did={did} />
2479
2479
+
</div>
2480
2480
+
))}
2481
2481
+
</div>
2482
2482
+
)}
2438
2483
2439
2484
{/* Vote count */}
2440
2485
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
···
2846
2891
width: "100%",
2847
2892
aspectRatio: image.aspectRatio
2848
2893
? (() => {
2849
2849
-
const { width, height } = image.aspectRatio;
2850
2850
-
const ratio = width / height;
2851
2851
-
return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2852
2852
-
})()
2894
2894
+
const { width, height } = image.aspectRatio;
2895
2895
+
const ratio = width / height;
2896
2896
+
return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2897
2897
+
})()
2853
2898
: "1 / 1", // fallback to square
2854
2899
//backgroundColor: theme.background, // fallback letterboxing color
2855
2900
borderRadius: 12,
···
3547
3592
borderRadius: 12,
3548
3593
overflow: "hidden",
3549
3594
//border: `1px solid ${theme.border}`,
3550
3550
-
paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3551
3551
-
}%`, // 16:9 = 56.25%, 4:3 = 75%
3595
3595
+
paddingTop: `${
3596
3596
+
100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3597
3597
+
}%`, // 16:9 = 56.25%, 4:3 = 75%
3552
3598
}}
3553
3599
className="border border-gray-200 dark:border-gray-800 was7"
3554
3600
>
+174
-58
src/providers/PollMutationQueueProvider.tsx
···
1
1
+
import { useQueryClient } from "@tanstack/react-query";
1
2
import { useAtom } from "jotai";
2
3
import React, { createContext, use, useCallback, useMemo } from "react";
3
4
···
27
28
) => Promise<void>;
28
29
29
30
getLocalVotes: (pollUri: string) => ExtendedLocalVote[];
31
31
+
refreshPollData: (pollUri?: string) => void;
30
32
}
31
33
32
34
const PollMutationContext = createContext<PollMutationContextType | undefined>(
···
43
45
children: React.ReactNode;
44
46
}) {
45
47
const { agent } = useAuth();
48
48
+
const queryClient = useQueryClient();
46
49
const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom);
47
50
48
51
const getLocalVotes = useCallback(
···
53
56
);
54
57
55
58
const updateLocalState = useCallback(
56
56
-
(pollUri: string, updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[]) => {
59
59
+
(
60
60
+
pollUri: string,
61
61
+
updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[],
62
62
+
) => {
57
63
setLocalVotes((prev) => ({
58
64
...prev,
59
65
[pollUri]: updater((prev[pollUri] || []) as ExtendedLocalVote[]),
···
62
68
[setLocalVotes],
63
69
);
64
70
71
71
+
const refreshPollData = useCallback(
72
72
+
(pollUri?: string) => {
73
73
+
// Clear all local pending votes for this poll or all polls
74
74
+
if (pollUri) {
75
75
+
// Clear local state for specific poll
76
76
+
setLocalVotes((prev) => {
77
77
+
const newState = { ...prev };
78
78
+
delete newState[pollUri];
79
79
+
return newState;
80
80
+
});
81
81
+
} else {
82
82
+
// Clear all local votes
83
83
+
setLocalVotes({});
84
84
+
}
85
85
+
86
86
+
// Invalidate all poll constellation queries using predicate function
87
87
+
queryClient.invalidateQueries({
88
88
+
predicate: (query) => {
89
89
+
const queryKey = query.queryKey;
90
90
+
return (
91
91
+
Array.isArray(queryKey) && queryKey.includes("constellation-polls")
92
92
+
);
93
93
+
},
94
94
+
});
95
95
+
96
96
+
// If specific poll URI provided, also invalidate that poll's data
97
97
+
if (pollUri) {
98
98
+
queryClient.invalidateQueries({
99
99
+
queryKey: ["arbitrary", pollUri],
100
100
+
});
101
101
+
}
102
102
+
},
103
103
+
[queryClient, setLocalVotes],
104
104
+
);
105
105
+
65
106
const castVoteRaw = useCallback(
66
107
async (
67
108
pollUri: string,
···
81
122
82
123
// Check if ANY server vote exists for this option
83
124
const hasServerVote = currentServerVotes.some((uri) =>
84
84
-
uri.includes(`app.reddwarf.poll.vote.${optionKey}`)
125
125
+
uri.includes(`app.reddwarf.poll.vote.${optionKey}`),
85
126
);
86
127
87
128
const isCurrentlyVoted = localEntry
···
92
133
// ACTION: UNVOTE (Toggle Off)
93
134
// ------------------------------------------------------------
94
135
if (isCurrentlyVoted) {
95
95
-
96
136
// Optimistic Update: Tombstone
97
137
updateLocalState(pollUri, (prev) => {
98
98
-
const clean = prev.filter(v => v.option !== optionKey);
99
99
-
return [...clean, {
100
100
-
pollUri,
101
101
-
option: optionKey,
102
102
-
status: "pending",
103
103
-
action: "delete",
104
104
-
timestamp
105
105
-
}];
138
138
+
const clean = prev.filter((v) => v.option !== optionKey);
139
139
+
return [
140
140
+
...clean,
141
141
+
{
142
142
+
pollUri,
143
143
+
option: optionKey,
144
144
+
status: "pending",
145
145
+
action: "delete",
146
146
+
timestamp,
147
147
+
},
148
148
+
];
106
149
});
107
150
108
151
try {
109
152
// FIX: Collect ALL URIs for this option (Server + Local)
110
153
// We want to nuke every record that matches this option to clean up state
111
111
-
const serverUris = currentServerVotes.filter(uri =>
112
112
-
uri.includes(`app.reddwarf.poll.vote.${optionKey}`)
154
154
+
const serverUris = currentServerVotes.filter((uri) =>
155
155
+
uri.includes(`app.reddwarf.poll.vote.${optionKey}`),
113
156
);
114
157
115
158
const urisToDelete = [...serverUris];
···
122
165
123
166
// Parallel delete for everything found
124
167
await Promise.all(
125
125
-
uniqueUris.map(uri => {
168
168
+
uniqueUris.map((uri) => {
126
169
const match = uri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
127
170
if (!match) return Promise.resolve();
128
171
const [, repo, collection, rkey] = match;
···
131
174
collection,
132
175
rkey,
133
176
});
134
134
-
})
177
177
+
}),
135
178
);
136
136
-
137
179
} catch (e) {
138
180
console.error("Failed to unvote", e);
139
181
renderSnack({ title: "Failed to remove vote" });
140
182
// Revert optimistic update
141
141
-
updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp));
183
183
+
updateLocalState(pollUri, (prev) =>
184
184
+
prev.filter((v) => v.timestamp !== timestamp),
185
185
+
);
142
186
}
143
187
}
144
188
···
146
190
// ACTION: VOTE (Toggle On)
147
191
// ------------------------------------------------------------
148
192
else {
149
149
-
// ... (The Vote logic remains the same, as the Single Choice cleanup
193
193
+
// ... (The Vote logic remains the same, as the Single Choice cleanup
150
194
// logic there already iterated over the entire array) ...
151
195
152
196
updateLocalState(pollUri, (prev) => {
153
153
-
const newState = isMultiple ? [...prev] : prev.filter(v => v.action !== 'create');
154
154
-
const clean = newState.filter(v => v.option !== optionKey);
155
155
-
return [...clean, {
156
156
-
pollUri,
157
157
-
option: optionKey,
158
158
-
status: "pending",
159
159
-
action: "create",
160
160
-
timestamp
161
161
-
}];
197
197
+
const newState = isMultiple
198
198
+
? [...prev]
199
199
+
: prev.filter((v) => v.action !== "create");
200
200
+
const clean = newState.filter((v) => v.option !== optionKey);
201
201
+
return [
202
202
+
...clean,
203
203
+
{
204
204
+
pollUri,
205
205
+
option: optionKey,
206
206
+
status: "pending",
207
207
+
action: "create",
208
208
+
timestamp,
209
209
+
},
210
210
+
];
162
211
});
163
212
164
213
// Cleanup others if single choice
165
214
if (!isMultiple) {
166
215
const votesToDelete = [
167
216
...currentServerVotes,
168
168
-
...(currentLocal.filter(v => v.action === 'create' && v.uri).map(v => v.uri) as string[])
217
217
+
...(currentLocal
218
218
+
.filter((v) => v.action === "create" && v.uri)
219
219
+
.map((v) => v.uri) as string[]),
169
220
];
170
221
171
222
// This was already safe because it iterates the whole array
···
174
225
const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
175
226
if (match) {
176
227
const [, repo, collection, rkey] = match;
177
177
-
agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error);
228
228
+
agent.com.atproto.repo
229
229
+
.deleteRecord({ repo, collection, rkey })
230
230
+
.catch(console.error);
178
231
}
179
232
});
180
233
}
···
192
245
});
193
246
194
247
updateLocalState(pollUri, (prev) => {
195
195
-
const clean = prev.filter(v => v.option !== optionKey);
196
196
-
return [...clean, {
197
197
-
pollUri,
198
198
-
option: optionKey,
199
199
-
status: "confirmed",
200
200
-
action: "create",
201
201
-
uri: res.data.uri,
202
202
-
timestamp: Date.now(),
203
203
-
}];
248
248
+
const clean = prev.filter((v) => v.option !== optionKey);
249
249
+
return [
250
250
+
...clean,
251
251
+
{
252
252
+
pollUri,
253
253
+
option: optionKey,
254
254
+
status: "confirmed",
255
255
+
action: "create",
256
256
+
uri: res.data.uri,
257
257
+
timestamp: Date.now(),
258
258
+
},
259
259
+
];
204
260
});
205
261
} catch (e) {
206
262
console.error("Vote failed", e);
207
263
renderSnack({ title: "Vote failed" });
208
208
-
updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp));
264
264
+
updateLocalState(pollUri, (prev) =>
265
265
+
prev.filter((v) => v.timestamp !== timestamp),
266
266
+
);
209
267
}
210
268
}
211
269
},
···
213
271
);
214
272
215
273
return (
216
216
-
<PollMutationContext value={{ castVoteRaw, getLocalVotes }}>
274
274
+
<PollMutationContext
275
275
+
value={{ castVoteRaw, getLocalVotes, refreshPollData }}
276
276
+
>
217
277
{children}
218
278
</PollMutationContext>
219
279
);
···
234
294
const agentDid = agent?.did;
235
295
236
296
const userVotesA = useGetOneToOneState(
237
237
-
agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri" } : undefined
297
297
+
agentDid
298
298
+
? {
299
299
+
target: pollUri,
300
300
+
user: agentDid,
301
301
+
collection: "app.reddwarf.poll.vote.a",
302
302
+
path: ".subject.uri",
303
303
+
}
304
304
+
: undefined,
238
305
);
239
306
const userVotesB = useGetOneToOneState(
240
240
-
agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri" } : undefined
307
307
+
agentDid
308
308
+
? {
309
309
+
target: pollUri,
310
310
+
user: agentDid,
311
311
+
collection: "app.reddwarf.poll.vote.b",
312
312
+
path: ".subject.uri",
313
313
+
}
314
314
+
: undefined,
241
315
);
242
316
const userVotesC = useGetOneToOneState(
243
243
-
agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri" } : undefined
317
317
+
agentDid
318
318
+
? {
319
319
+
target: pollUri,
320
320
+
user: agentDid,
321
321
+
collection: "app.reddwarf.poll.vote.c",
322
322
+
path: ".subject.uri",
323
323
+
}
324
324
+
: undefined,
244
325
);
245
326
const userVotesD = useGetOneToOneState(
246
246
-
agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri" } : undefined
327
327
+
agentDid
328
328
+
? {
329
329
+
target: pollUri,
330
330
+
user: agentDid,
331
331
+
collection: "app.reddwarf.poll.vote.d",
332
332
+
path: ".subject.uri",
333
333
+
}
334
334
+
: undefined,
247
335
);
248
336
249
337
return useMemo(() => {
···
273
361
// 1. FETCHING - Move the logic here
274
362
// We only need the first page/subset to show avatars
275
363
const { data: votersA } = useQueryConstellation({
276
276
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri",
364
364
+
method: "/links",
365
365
+
target: pollUri,
366
366
+
collection: "app.reddwarf.poll.vote.a",
367
367
+
path: ".subject.uri",
368
368
+
customkey: "constellation-polls",
277
369
});
278
370
const { data: votersB } = useQueryConstellation({
279
279
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri",
371
371
+
method: "/links",
372
372
+
target: pollUri,
373
373
+
collection: "app.reddwarf.poll.vote.b",
374
374
+
path: ".subject.uri",
375
375
+
customkey: "constellation-polls",
280
376
});
281
377
const { data: votersC } = useQueryConstellation({
282
282
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri",
378
378
+
method: "/links",
379
379
+
target: pollUri,
380
380
+
collection: "app.reddwarf.poll.vote.c",
381
381
+
path: ".subject.uri",
382
382
+
customkey: "constellation-polls",
283
383
});
284
384
const { data: votersD } = useQueryConstellation({
285
285
-
method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri",
385
385
+
method: "/links",
386
386
+
target: pollUri,
387
387
+
collection: "app.reddwarf.poll.vote.d",
388
388
+
path: ".subject.uri",
389
389
+
customkey: "constellation-polls",
286
390
});
287
391
288
288
-
const handleVote = useCallback((optionKey: string) => {
289
289
-
if (!pollCid) return;
290
290
-
castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes);
291
291
-
}, [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw]);
392
392
+
const handleVote = useCallback(
393
393
+
(optionKey: string) => {
394
394
+
if (!pollCid) return;
395
395
+
castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes);
396
396
+
},
397
397
+
[pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw],
398
398
+
);
292
399
293
400
return useMemo(() => {
294
401
// Helper to clean a raw list: extract DIDs, Deduplicate, Remove Self
···
314
421
// --- LOGIC: Determine if we have voted (Boolean) ---
315
422
const localEntry = localVotes.find((v) => v.option === option);
316
423
const isServerVoted = serverUserVotes.some((uri) =>
317
317
-
uri.includes(`app.reddwarf.poll.vote.${option}`)
424
424
+
uri.includes(`app.reddwarf.poll.vote.${option}`),
318
425
);
319
426
320
427
let hasVoted = false;
···
326
433
hasVoted = isServerVoted;
327
434
} else {
328
435
// Single choice: if we created a vote elsewhere locally, this one is false
329
329
-
const hasSwitched = localVotes.some((v) => v.option !== option && v.action === "create");
436
436
+
const hasSwitched = localVotes.some(
437
437
+
(v) => v.option !== option && v.action === "create",
438
438
+
);
330
439
hasVoted = hasSwitched ? false : isServerVoted;
331
440
}
332
441
}
···
348
457
hasVoted,
349
458
count,
350
459
// We only return the DIDs now, top 5
351
351
-
topVoterDids: finalVoters.slice(0, 5)
460
460
+
topVoterDids: finalVoters.slice(0, 5),
352
461
};
353
462
};
354
463
···
359
468
360
469
return {
361
470
results: { a: stateA, b: stateB, c: stateC, d: stateD },
362
362
-
hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted,
471
471
+
hasVotedAny:
472
472
+
stateA.hasVoted ||
473
473
+
stateB.hasVoted ||
474
474
+
stateC.hasVoted ||
475
475
+
stateD.hasVoted,
363
476
totalVotes: stateA.count + stateB.count + stateC.count + stateD.count,
364
477
handleVote,
365
478
};
···
367
480
localVotes,
368
481
serverUserVotes,
369
482
serverCounts,
370
370
-
votersA, votersB, votersC, votersD, // Dependencies for fetching
483
483
+
votersA,
484
484
+
votersB,
485
485
+
votersC,
486
486
+
votersD, // Dependencies for fetching
371
487
isMultiple,
372
488
handleVote,
373
489
myDid,
374
490
]);
375
375
-
}
491
491
+
}
+14
-12
src/utils/followState.ts
···
1
1
-
import { type Agent,AtUri } from "@atproto/api";
1
1
+
import { type Agent, AtUri } from "@atproto/api";
2
2
import { TID } from "@atproto/common-web";
3
3
import type { QueryClient } from "@tanstack/react-query";
4
4
5
5
-
import { type linksRecordsResponse,useQueryConstellation } from "./useQuery";
5
5
+
import { type linksRecordsResponse, useQueryConstellation } from "./useQuery";
6
6
7
7
export function useGetFollowState({
8
8
target,
···
21
21
path: ".subject",
22
22
dids: [user],
23
23
}
24
24
-
: { method: "undefined", target: "whatever" }
24
24
+
: { method: "undefined", target: "whatever" },
25
25
// overloading sucks so much
26
26
) as { data: linksRecordsResponse | undefined };
27
27
const follows = followData?.linking_records.slice(0, 50) ?? [];
···
60
60
61
61
const updateCache = (
62
62
updater: (
63
63
-
oldData: linksRecordsResponse | undefined
64
64
-
) => linksRecordsResponse | undefined
63
63
+
oldData: linksRecordsResponse | undefined,
64
64
+
) => linksRecordsResponse | undefined,
65
65
) => {
66
66
queryClient.setQueryData(
67
67
queryKey,
68
68
-
(oldData: linksRecordsResponse | undefined) => updater(oldData)
68
68
+
(oldData: linksRecordsResponse | undefined) => updater(oldData),
69
69
);
70
70
};
71
71
···
122
122
linking_records: old.linking_records.filter(
123
123
(rec) =>
124
124
!followRecords.includes(
125
125
-
`at://${rec.did}/${rec.collection}/${rec.rkey}`
126
126
-
)
125
125
+
`at://${rec.did}/${rec.collection}/${rec.rkey}`,
126
126
+
),
127
127
),
128
128
};
129
129
});
130
130
}
131
131
-
132
132
-
133
131
134
132
export function useGetOneToOneState(params?: {
135
133
target: string;
···
146
144
collection: params.collection,
147
145
path: params.path,
148
146
dids: [params.user],
147
147
+
// todo disgusting hack please never code again
148
148
+
customkey: params.collection.includes("reddwarf.poll.vote")
149
149
+
? "constellation-polls"
150
150
+
: undefined,
149
151
}
150
150
-
: { method: "undefined", target: "whatever" }
152
152
+
: { method: "undefined", target: "whatever" },
151
153
// overloading sucks so much
152
154
) as { data: linksRecordsResponse | undefined };
153
155
if (!params || !params.user) return undefined;
···
160
162
}
161
163
162
164
return undefined;
163
163
-
}
165
165
+
}
+9
src/utils/useQuery.ts
···
239
239
path?: string;
240
240
cursor?: string;
241
241
dids?: string[];
242
242
+
customkey?: string;
242
243
}) {
243
244
// : QueryOptions<
244
245
// | linksRecordsResponse
···
257
258
query?.path,
258
259
query?.cursor,
259
260
query?.dids,
261
261
+
query?.customkey,
260
262
] as const,
261
263
queryFn: async () => {
262
264
if (!query || query.method === "undefined") return undefined as undefined;
···
322
324
path: string;
323
325
cursor?: string;
324
326
dids?: string[];
327
327
+
customkey?: string;
325
328
}): UseQueryResult<linksRecordsResponse, Error>;
326
329
export function useQueryConstellation(query: {
327
330
method: "/links/distinct-dids";
···
329
332
collection: string;
330
333
path: string;
331
334
cursor?: string;
335
335
+
customkey?: string;
332
336
}): UseQueryResult<linksDidsResponse, Error>;
333
337
export function useQueryConstellation(query: {
334
338
method: "/links/count";
···
336
340
collection: string;
337
341
path: string;
338
342
cursor?: string;
343
343
+
customkey?: string;
339
344
}): UseQueryResult<linksCountResponse, Error>;
340
345
export function useQueryConstellation(query: {
341
346
method: "/links/count/distinct-dids";
···
343
348
collection: string;
344
349
path: string;
345
350
cursor?: string;
351
351
+
customkey?: string;
346
352
}): UseQueryResult<linksCountResponse, Error>;
347
353
export function useQueryConstellation(query: {
348
354
method: "/links/all";
349
355
target: string;
356
356
+
customkey?: string;
350
357
}): UseQueryResult<linksAllResponse, Error>;
351
358
export function useQueryConstellation(): undefined;
352
359
export function useQueryConstellation(query: {
353
360
method: "undefined";
354
361
target: string;
362
362
+
customkey?: string;
355
363
}): undefined;
356
364
export function useQueryConstellation(query?: {
357
365
method:
···
366
374
path?: string;
367
375
cursor?: string;
368
376
dids?: string[];
377
377
+
customkey?: string;
369
378
}):
370
379
| UseQueryResult<
371
380
| linksRecordsResponse