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 state
whey.party
1 month ago
99fbcac7
76d6a758
+589
-213
4 changed files
expand all
collapse all
unified
split
src
components
UniversalPostRenderer.tsx
providers
PollMutationQueueProvider.tsx
routes
__root.tsx
utils
atoms.ts
+243
-204
src/components/UniversalPostRenderer.tsx
···
1
import * as ATPAPI from "@atproto/api";
2
-
import { useQueryClient } from "@tanstack/react-query";
3
import { useNavigate } from "@tanstack/react-router";
4
import DOMPurify from "dompurify";
5
import { useAtom } from "jotai";
···
14
enableBridgyTextAtom,
15
enableWafrnTextAtom,
16
imgCDNAtom,
17
-
slingshotURLAtom,
18
} from "~/utils/atoms";
19
import { useGetOneToOneState } from "~/utils/followState";
20
import { useHydratedEmbed } from "~/utils/useHydrated";
···
411
setReplies(
412
links
413
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
414
-
?.records || 0
415
: null,
416
);
417
}, [links]);
···
459
460
const replyAturis = repliesData
461
? repliesData.pages.flatMap((page) =>
462
-
page
463
-
? page.linking_records.map((record) => {
464
-
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
465
-
return aturi;
466
-
})
467
-
: [],
468
-
)
469
: [];
470
471
//const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
···
625
opacity: 0.5,
626
}}
627
className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
628
-
//className="border-gray-400 dark:border-gray-500"
629
/>
630
</div>
631
···
771
const isQuotewithImages =
772
isquotewithmedia &&
773
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
774
-
"app.bsky.embed.images";
775
const isQuotewithVideo =
776
isquotewithmedia &&
777
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
778
-
"app.bsky.embed.video";
779
780
const hasMedia =
781
hasEmbed &&
···
1259
import ReactPlayer from "react-player";
1260
1261
import defaultpfp from "~/../public/favicon.png";
0
0
0
0
1262
import { useAuth } from "~/providers/UnifiedAuthProvider";
1263
import { renderSnack } from "~/routes/__root";
1264
import {
···
1494
1495
const tags = unfediwafrnTags
1496
? unfediwafrnTags
1497
-
.split("\n")
1498
-
.map((t) => t.trim())
1499
-
.filter(Boolean)
1500
: undefined;
1501
1502
const links = tags
1503
? tags
1504
-
.map((tag) => {
1505
-
const encoded = encodeURIComponent(tag);
1506
-
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1507
-
})
1508
-
.join("<br>")
1509
: "";
1510
1511
const unfediwafrn = unfediwafrnPartial
···
1518
1519
/* fuck you */
1520
const isMainItem = false;
1521
-
const setMainItem = (any: any) => { };
1522
// eslint-disable-next-line react-hooks/refs
1523
//console.log("Received ref in UniversalPostRenderer:", usedref);
1524
return (
···
1532
: setMainItem
1533
? onPostClick
1534
? (e) => {
1535
-
setMainItem({ post: post });
1536
-
onPostClick(e);
1537
-
}
1538
: () => {
1539
-
setMainItem({ post: post });
1540
-
}
1541
: undefined
1542
}
1543
style={{
···
2020
try {
2021
await navigator.clipboard.writeText(
2022
"https://bsky.app" +
2023
-
"/profile/" +
2024
-
post.author.handle +
2025
-
"/post/" +
2026
-
post.uri.split("/").pop(),
2027
);
2028
renderSnack({
2029
title: "Copied to clipboard!",
···
2131
| AppBskyEmbedVideo.View
2132
| AppBskyEmbedExternal.View
2133
| AppBskyEmbedRecordWithMedia.View
2134
-
| { $type: string;[k: string]: unknown };
2135
2136
enum PostEmbedViewContext {
2137
ThreadHighlighted = "ThreadHighlighted",
···
2150
const { agent } = useAuth();
2151
const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`;
2152
const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri);
0
2153
2154
// Query vote counts for each option
2155
-
const [constellationurl] = useAtom(constellationURLAtom);
2156
-
const [imgcdn] = useAtom(imgCDNAtom);
2157
-
const [slingshoturl] = useAtom(slingshotURLAtom);
2158
-
const queryClient = useQueryClient();
2159
-
2160
const { data: voteCountsA } = useQueryConstellation({
2161
method: "/links/count/distinct-dids",
2162
target: pollUri,
···
2218
const userVotesA = useGetOneToOneState(
2219
agent?.did
2220
? {
2221
-
target: pollUri,
2222
-
user: agent?.did,
2223
-
collection: "app.reddwarf.poll.vote.a",
2224
-
path: ".subject.uri",
2225
-
}
2226
: undefined,
2227
);
2228
2229
const userVotesB = useGetOneToOneState(
2230
agent?.did
2231
? {
2232
-
target: pollUri,
2233
-
user: agent?.did,
2234
-
collection: "app.reddwarf.poll.vote.b",
2235
-
path: ".subject.uri",
2236
-
}
2237
: undefined,
2238
);
2239
2240
const userVotesC = useGetOneToOneState(
2241
agent?.did
2242
? {
2243
-
target: pollUri,
2244
-
user: agent?.did,
2245
-
collection: "app.reddwarf.poll.vote.c",
2246
-
path: ".subject.uri",
2247
-
}
2248
: undefined,
2249
);
2250
2251
const userVotesD = useGetOneToOneState(
2252
agent?.did
2253
? {
2254
-
target: pollUri,
2255
-
user: agent?.did,
2256
-
collection: "app.reddwarf.poll.vote.d",
2257
-
path: ".subject.uri",
2258
-
}
2259
: undefined,
2260
);
2261
2262
-
2263
-
2264
// todo: hardcoded to multiple for all public polls
2265
const poll = {
2266
...(pollRecord?.value ?? {}),
···
2277
2278
const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean);
2279
2280
-
// Calculate vote counts
2281
-
const voteData = [
2282
-
{
2283
-
option: "a",
2284
-
count: parseInt((voteCountsA as any)?.total || "0"),
2285
-
voters: votersA?.linking_records || [],
2286
-
},
2287
-
{
2288
-
option: "b",
2289
-
count: parseInt((voteCountsB as any)?.total || "0"),
2290
-
voters: votersB?.linking_records || [],
2291
-
},
2292
-
{
2293
-
option: "c",
2294
-
count: parseInt((voteCountsC as any)?.total || "0"),
2295
-
voters: votersC?.linking_records || [],
2296
-
},
2297
-
{
2298
-
option: "d",
2299
-
count: parseInt((voteCountsD as any)?.total || "0"),
2300
-
voters: votersD?.linking_records || [],
2301
-
},
2302
-
].slice(0, options.length);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2303
2304
if (isLoading) {
2305
return (
···
2333
// })
2334
// : null;
2335
0
2336
2337
-
const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0);
0
2338
2339
-
const handleVote = async (option: string) => {
2340
-
if (!agent || isExpired) return;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2341
2342
-
try {
2343
-
// Get existing votes for this option
2344
-
const existingVotes = (() => {
2345
-
switch (option) {
2346
-
case "a":
2347
-
return userVotesA;
2348
-
case "b":
2349
-
return userVotesB;
2350
-
case "c":
2351
-
return userVotesC;
2352
-
case "d":
2353
-
return userVotesD;
2354
-
default:
2355
-
return [];
2356
-
}
2357
-
})();
0
0
0
0
0
0
0
0
0
0
2358
2359
-
// If user has already voted for this option, delete all votes (unvote)
2360
-
if (existingVotes && existingVotes.length > 0) {
2361
-
for (const voteUri of existingVotes) {
2362
-
const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
2363
-
if (match) {
2364
-
const [, did, collection, rkey] = match;
2365
-
await agent.com.atproto.repo.deleteRecord({
2366
-
repo: did,
2367
-
collection,
2368
-
rkey,
2369
-
});
2370
-
}
2371
-
}
2372
-
} else {
2373
-
// If not voted for this option, create new vote
2374
-
// First, delete votes from other options if poll doesn't allow multiple votes
2375
-
if (!poll.multiple) {
2376
-
const otherVotes = [
2377
-
...(userVotesA || []),
2378
-
...(userVotesB || []),
2379
-
...(userVotesC || []),
2380
-
...(userVotesD || []),
2381
-
].filter((vote) => {
2382
-
// Filter out votes for the current option
2383
-
return !vote.includes(`app.reddwarf.poll.vote.${option}`);
2384
-
});
2385
2386
-
for (const voteUri of otherVotes) {
2387
-
const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
2388
-
if (match) {
2389
-
const [, did, collection, rkey] = match;
2390
-
await agent.com.atproto.repo.deleteRecord({
2391
-
repo: did,
2392
-
collection,
2393
-
rkey,
2394
-
});
2395
-
}
2396
-
}
2397
-
}
2398
-
2399
-
// Create new vote
2400
-
await agent.com.atproto.repo.createRecord({
2401
-
collection: `app.reddwarf.poll.vote.${option}`,
2402
-
repo: agent.assertDid,
2403
-
record: {
2404
-
$type: `app.reddwarf.poll.vote.${option}`,
2405
-
subject: {
2406
-
$type: "com.atproto.repo.strongRef",
2407
-
uri: pollUri,
2408
-
cid: pollRecord.cid,
2409
-
},
2410
-
createdAt: new Date().toISOString(),
2411
-
},
2412
-
// Let PDS generate rkey automatically
2413
-
});
2414
-
}
2415
-
} catch (error) {
2416
-
console.error("Failed to vote:", error);
2417
-
}
2418
-
};
2419
2420
return (
2421
<>
···
2430
2431
{/* Multiplicity */}
2432
<span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1">
2433
-
{poll.multiple ? (<IconMdiCheckboxMultipleMarked />) : (<IconMdiCheckCircle />)}
0
0
0
0
2434
{poll.multiple ? "Select one or more options" : "Select one option"}
2435
</span>
2436
-
2437
</div>
2438
2439
{/* Options List with Results */}
2440
<div className="space-y-3">
2441
{options.map((optionText, index) => {
2442
-
const optionKey = ["a", "b", "c", "d"][index];
0
0
0
0
2443
2444
-
// Check if user has voted for this option
2445
-
const userVotesForOption = (() => {
0
0
0
0
0
0
0
0
0
2446
switch (optionKey) {
2447
case "a":
2448
-
return userVotesA;
2449
case "b":
2450
-
return userVotesB;
2451
case "c":
2452
-
return userVotesC;
2453
case "d":
2454
-
return userVotesD;
2455
default:
2456
return [];
2457
}
2458
})();
2459
2460
-
const rowData = voteData.find((v) => v.option === optionKey);
2461
-
const hasVotedForOption =
2462
-
userVotesForOption && userVotesForOption.length > 0;
2463
-
const voteCount =
2464
-
voteData.find((v) => v.option === optionKey)?.count ?? 0;
2465
-
const votePercentage =
2466
-
totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0;
2467
-
2468
-
// Extract just the DIDs we want to show (top 2)
2469
-
const topVoters = rowData?.voters
2470
-
.filter(v => !!v.did)
2471
-
.slice(0, 5) || [];
2472
2473
return (
2474
<div
2475
key={index}
2476
-
className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${!isExpired
2477
-
? hasVotedForOption
2478
-
? "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"
2479
-
: "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"
2480
-
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700"
2481
-
}`}
0
2482
onClick={(e) => {
2483
e.stopPropagation();
2484
if (!isExpired) {
2485
-
handleVote(optionKey)
2486
}
2487
}}
2488
>
···
2515
style={{ zIndex: 5 - idx }}
2516
>
2517
{/* The Component handles the async fetch! */}
2518
-
<PollOptionAvatar
2519
-
did={voter.did}
2520
-
/>
2521
</div>
2522
))}
2523
</div>
···
2569
}}
2570
className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]"
2571
>
2572
-
View all {totalVotes} votes
2573
</button>
2574
</div>
2575
</div>
···
2585
);
2586
}
2587
2588
-
function PollOptionAvatar({
2589
-
did,
2590
-
}: {
2591
-
did: string;
2592
-
}) {
2593
const [imgcdn] = useAtom(imgCDNAtom);
2594
// Each avatar handles its own data fetching
2595
// If this specific DID is already in cache, it loads instantly
2596
-
const { data: profileRecord } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`)
0
0
2597
2598
//const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record;
2599
const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn);
···
2935
width: "100%",
2936
aspectRatio: image.aspectRatio
2937
? (() => {
2938
-
const { width, height } = image.aspectRatio;
2939
-
const ratio = width / height;
2940
-
return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2941
-
})()
2942
: "1 / 1", // fallback to square
2943
//backgroundColor: theme.background, // fallback letterboxing color
2944
borderRadius: 12,
···
3636
borderRadius: 12,
3637
overflow: "hidden",
3638
//border: `1px solid ${theme.border}`,
3639
-
paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3640
-
}%`, // 16:9 = 56.25%, 4:3 = 75%
0
3641
}}
3642
className="border border-gray-200 dark:border-gray-800 was7"
3643
>
···
1
import * as ATPAPI from "@atproto/api";
0
2
import { useNavigate } from "@tanstack/react-router";
3
import DOMPurify from "dompurify";
4
import { useAtom } from "jotai";
···
13
enableBridgyTextAtom,
14
enableWafrnTextAtom,
15
imgCDNAtom,
0
16
} from "~/utils/atoms";
17
import { useGetOneToOneState } from "~/utils/followState";
18
import { useHydratedEmbed } from "~/utils/useHydrated";
···
409
setReplies(
410
links
411
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
412
+
?.records || 0
413
: null,
414
);
415
}, [links]);
···
457
458
const replyAturis = repliesData
459
? repliesData.pages.flatMap((page) =>
460
+
page
461
+
? page.linking_records.map((record) => {
462
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
463
+
return aturi;
464
+
})
465
+
: [],
466
+
)
467
: [];
468
469
//const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
···
623
opacity: 0.5,
624
}}
625
className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
626
+
//className="border-gray-400 dark:border-gray-500"
627
/>
628
</div>
629
···
769
const isQuotewithImages =
770
isquotewithmedia &&
771
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
772
+
"app.bsky.embed.images";
773
const isQuotewithVideo =
774
isquotewithmedia &&
775
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
776
+
"app.bsky.embed.video";
777
778
const hasMedia =
779
hasEmbed &&
···
1257
import ReactPlayer from "react-player";
1258
1259
import defaultpfp from "~/../public/favicon.png";
1260
+
import {
1261
+
usePollData,
1262
+
usePollMutationQueue,
1263
+
} from "~/providers/PollMutationQueueProvider";
1264
import { useAuth } from "~/providers/UnifiedAuthProvider";
1265
import { renderSnack } from "~/routes/__root";
1266
import {
···
1496
1497
const tags = unfediwafrnTags
1498
? unfediwafrnTags
1499
+
.split("\n")
1500
+
.map((t) => t.trim())
1501
+
.filter(Boolean)
1502
: undefined;
1503
1504
const links = tags
1505
? tags
1506
+
.map((tag) => {
1507
+
const encoded = encodeURIComponent(tag);
1508
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1509
+
})
1510
+
.join("<br>")
1511
: "";
1512
1513
const unfediwafrn = unfediwafrnPartial
···
1520
1521
/* fuck you */
1522
const isMainItem = false;
1523
+
const setMainItem = (any: any) => {};
1524
// eslint-disable-next-line react-hooks/refs
1525
//console.log("Received ref in UniversalPostRenderer:", usedref);
1526
return (
···
1534
: setMainItem
1535
? onPostClick
1536
? (e) => {
1537
+
setMainItem({ post: post });
1538
+
onPostClick(e);
1539
+
}
1540
: () => {
1541
+
setMainItem({ post: post });
1542
+
}
1543
: undefined
1544
}
1545
style={{
···
2022
try {
2023
await navigator.clipboard.writeText(
2024
"https://bsky.app" +
2025
+
"/profile/" +
2026
+
post.author.handle +
2027
+
"/post/" +
2028
+
post.uri.split("/").pop(),
2029
);
2030
renderSnack({
2031
title: "Copied to clipboard!",
···
2133
| AppBskyEmbedVideo.View
2134
| AppBskyEmbedExternal.View
2135
| AppBskyEmbedRecordWithMedia.View
2136
+
| { $type: string; [k: string]: unknown };
2137
2138
enum PostEmbedViewContext {
2139
ThreadHighlighted = "ThreadHighlighted",
···
2152
const { agent } = useAuth();
2153
const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`;
2154
const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri);
2155
+
const { castVote } = usePollMutationQueue();
2156
2157
// Query vote counts for each option
0
0
0
0
0
2158
const { data: voteCountsA } = useQueryConstellation({
2159
method: "/links/count/distinct-dids",
2160
target: pollUri,
···
2216
const userVotesA = useGetOneToOneState(
2217
agent?.did
2218
? {
2219
+
target: pollUri,
2220
+
user: agent?.did,
2221
+
collection: "app.reddwarf.poll.vote.a",
2222
+
path: ".subject.uri",
2223
+
}
2224
: undefined,
2225
);
2226
2227
const userVotesB = useGetOneToOneState(
2228
agent?.did
2229
? {
2230
+
target: pollUri,
2231
+
user: agent?.did,
2232
+
collection: "app.reddwarf.poll.vote.b",
2233
+
path: ".subject.uri",
2234
+
}
2235
: undefined,
2236
);
2237
2238
const userVotesC = useGetOneToOneState(
2239
agent?.did
2240
? {
2241
+
target: pollUri,
2242
+
user: agent?.did,
2243
+
collection: "app.reddwarf.poll.vote.c",
2244
+
path: ".subject.uri",
2245
+
}
2246
: undefined,
2247
);
2248
2249
const userVotesD = useGetOneToOneState(
2250
agent?.did
2251
? {
2252
+
target: pollUri,
2253
+
user: agent?.did,
2254
+
collection: "app.reddwarf.poll.vote.d",
2255
+
path: ".subject.uri",
2256
+
}
2257
: undefined,
2258
);
2259
0
0
2260
// todo: hardcoded to multiple for all public polls
2261
const poll = {
2262
...(pollRecord?.value ?? {}),
···
2273
2274
const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean);
2275
2276
+
// // Calculate vote counts
2277
+
// const voteData = [
2278
+
// {
2279
+
// option: "a",
2280
+
// count: parseInt((voteCountsA as any)?.total || "0"),
2281
+
// voters: votersA?.linking_records || [],
2282
+
// },
2283
+
// {
2284
+
// option: "b",
2285
+
// count: parseInt((voteCountsB as any)?.total || "0"),
2286
+
// voters: votersB?.linking_records || [],
2287
+
// },
2288
+
// {
2289
+
// option: "c",
2290
+
// count: parseInt((voteCountsC as any)?.total || "0"),
2291
+
// voters: votersC?.linking_records || [],
2292
+
// },
2293
+
// {
2294
+
// option: "d",
2295
+
// count: parseInt((voteCountsD as any)?.total || "0"),
2296
+
// voters: votersD?.linking_records || [],
2297
+
// },
2298
+
// ].slice(0, options.length);
2299
+
2300
+
const serverUserVotes = [
2301
+
...(userVotesA || []),
2302
+
...(userVotesB || []),
2303
+
...(userVotesC || []),
2304
+
...(userVotesD || []),
2305
+
];
2306
+
2307
+
// Flatten counts
2308
+
const serverCounts = {
2309
+
a: parseInt((voteCountsA as any)?.total || "0"),
2310
+
b: parseInt((voteCountsB as any)?.total || "0"),
2311
+
c: parseInt((voteCountsC as any)?.total || "0"),
2312
+
d: parseInt((voteCountsD as any)?.total || "0"),
2313
+
};
2314
+
2315
+
// 3. THE MAGIC HOOK
2316
+
const pollState = usePollData(
2317
+
pollUri,
2318
+
!!poll.multiple,
2319
+
serverCounts,
2320
+
serverUserVotes,
2321
+
);
2322
+
2323
+
// 4. Handle Vote Wrapper
2324
+
const handleVote = async (optionKey: string) => {
2325
+
if (!pollRecord) return;
2326
+
// Expiry check
2327
+
if (isExpired) return;
2328
+
2329
+
// Trigger the Provider logic
2330
+
await castVote(
2331
+
pollUri,
2332
+
pollRecord.cid,
2333
+
optionKey,
2334
+
!!poll.multiple,
2335
+
serverUserVotes,
2336
+
);
2337
+
};
2338
2339
if (isLoading) {
2340
return (
···
2368
// })
2369
// : null;
2370
2371
+
// const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0);
2372
2373
+
// const handleVote = async (option: string) => {
2374
+
// if (!agent || isExpired) return;
2375
2376
+
// try {
2377
+
// // Get existing votes for this option
2378
+
// const existingVotes = (() => {
2379
+
// switch (option) {
2380
+
// case "a":
2381
+
// return userVotesA;
2382
+
// case "b":
2383
+
// return userVotesB;
2384
+
// case "c":
2385
+
// return userVotesC;
2386
+
// case "d":
2387
+
// return userVotesD;
2388
+
// default:
2389
+
// return [];
2390
+
// }
2391
+
// })();
2392
2393
+
// // If user has already voted for this option, delete all votes (unvote)
2394
+
// if (existingVotes && existingVotes.length > 0) {
2395
+
// for (const voteUri of existingVotes) {
2396
+
// const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
2397
+
// if (match) {
2398
+
// const [, did, collection, rkey] = match;
2399
+
// await agent.com.atproto.repo.deleteRecord({
2400
+
// repo: did,
2401
+
// collection,
2402
+
// rkey,
2403
+
// });
2404
+
// }
2405
+
// }
2406
+
// } else {
2407
+
// // If not voted for this option, create new vote
2408
+
// // First, delete votes from other options if poll doesn't allow multiple votes
2409
+
// if (!poll.multiple) {
2410
+
// const otherVotes = [
2411
+
// ...(userVotesA || []),
2412
+
// ...(userVotesB || []),
2413
+
// ...(userVotesC || []),
2414
+
// ...(userVotesD || []),
2415
+
// ].filter((vote) => {
2416
+
// // Filter out votes for the current option
2417
+
// return !vote.includes(`app.reddwarf.poll.vote.${option}`);
2418
+
// });
2419
2420
+
// for (const voteUri of otherVotes) {
2421
+
// const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
2422
+
// if (match) {
2423
+
// const [, did, collection, rkey] = match;
2424
+
// await agent.com.atproto.repo.deleteRecord({
2425
+
// repo: did,
2426
+
// collection,
2427
+
// rkey,
2428
+
// });
2429
+
// }
2430
+
// }
2431
+
// }
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2432
2433
+
// // Create new vote
2434
+
// await agent.com.atproto.repo.createRecord({
2435
+
// collection: `app.reddwarf.poll.vote.${option}`,
2436
+
// repo: agent.assertDid,
2437
+
// record: {
2438
+
// $type: `app.reddwarf.poll.vote.${option}`,
2439
+
// subject: {
2440
+
// $type: "com.atproto.repo.strongRef",
2441
+
// uri: pollUri,
2442
+
// cid: pollRecord.cid,
2443
+
// },
2444
+
// createdAt: new Date().toISOString(),
2445
+
// },
2446
+
// // Let PDS generate rkey automatically
2447
+
// });
2448
+
// }
2449
+
// } catch (error) {
2450
+
// console.error("Failed to vote:", error);
2451
+
// }
2452
+
// };
0
0
0
0
0
0
0
0
0
0
0
0
0
2453
2454
return (
2455
<>
···
2464
2465
{/* Multiplicity */}
2466
<span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1">
2467
+
{poll.multiple ? (
2468
+
<IconMdiCheckboxMultipleMarked />
2469
+
) : (
2470
+
<IconMdiCheckCircle />
2471
+
)}
2472
{poll.multiple ? "Select one or more options" : "Select one option"}
2473
</span>
0
2474
</div>
2475
2476
{/* Options List with Results */}
2477
<div className="space-y-3">
2478
{options.map((optionText, index) => {
2479
+
const optionKey = ["a", "b", "c", "d"][index] as
2480
+
| "a"
2481
+
| "b"
2482
+
| "c"
2483
+
| "d";
2484
2485
+
// Get the state from the hook
2486
+
const optionState = pollState.results[optionKey];
2487
+
const hasVotedForOption = optionState.hasVoted;
2488
+
const voteCount = optionState.count;
2489
+
const votePercentage =
2490
+
pollState.totalVotes > 0
2491
+
? (voteCount / pollState.totalVotes) * 100
2492
+
: 0;
2493
+
2494
+
// Get the voters data for displaying avatars
2495
+
const votersData = (() => {
2496
switch (optionKey) {
2497
case "a":
2498
+
return votersA?.linking_records || [];
2499
case "b":
2500
+
return votersB?.linking_records || [];
2501
case "c":
2502
+
return votersC?.linking_records || [];
2503
case "d":
2504
+
return votersD?.linking_records || [];
2505
default:
2506
return [];
2507
}
2508
})();
2509
2510
+
// Extract just the DIDs we want to show (top 5)
2511
+
const topVoters =
2512
+
votersData.filter((v) => !!v.did).slice(0, 5) || [];
0
0
0
0
0
0
0
0
0
2513
2514
return (
2515
<div
2516
key={index}
2517
+
className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${
2518
+
!isExpired
2519
+
? hasVotedForOption
2520
+
? "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"
2521
+
: "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"
2522
+
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700"
2523
+
}`}
2524
onClick={(e) => {
2525
e.stopPropagation();
2526
if (!isExpired) {
2527
+
handleVote(optionKey);
2528
}
2529
}}
2530
>
···
2557
style={{ zIndex: 5 - idx }}
2558
>
2559
{/* The Component handles the async fetch! */}
2560
+
<PollOptionAvatar did={voter.did} />
0
0
2561
</div>
2562
))}
2563
</div>
···
2609
}}
2610
className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]"
2611
>
2612
+
View all {pollState.totalVotes} votes
2613
</button>
2614
</div>
2615
</div>
···
2625
);
2626
}
2627
2628
+
function PollOptionAvatar({ did }: { did: string }) {
0
0
0
0
2629
const [imgcdn] = useAtom(imgCDNAtom);
2630
// Each avatar handles its own data fetching
2631
// If this specific DID is already in cache, it loads instantly
2632
+
const { data: profileRecord } = useQueryProfile(
2633
+
`at://${did}/app.bsky.actor.profile/self`,
2634
+
);
2635
2636
//const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record;
2637
const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn);
···
2973
width: "100%",
2974
aspectRatio: image.aspectRatio
2975
? (() => {
2976
+
const { width, height } = image.aspectRatio;
2977
+
const ratio = width / height;
2978
+
return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2979
+
})()
2980
: "1 / 1", // fallback to square
2981
//backgroundColor: theme.background, // fallback letterboxing color
2982
borderRadius: 12,
···
3674
borderRadius: 12,
3675
overflow: "hidden",
3676
//border: `1px solid ${theme.border}`,
3677
+
paddingTop: `${
3678
+
100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3679
+
}%`, // 16:9 = 56.25%, 4:3 = 75%
3680
}}
3681
className="border border-gray-200 dark:border-gray-800 was7"
3682
>
+311
src/providers/PollMutationQueueProvider.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useAtom } from "jotai";
2
+
import React, { createContext, use, useCallback, useMemo } from "react";
3
+
4
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
5
+
import { renderSnack } from "~/routes/__root";
6
+
import { localPollVotesAtom, type LocalVote } from "~/utils/atoms";
7
+
8
+
interface PollMutationContextType {
9
+
castVote: (
10
+
pollUri: string,
11
+
pollCid: string,
12
+
option: string,
13
+
isMultiple: boolean,
14
+
currentServerVotes: string[] // Pass current user vote URIs to handle unvoting logic
15
+
) => Promise<void>;
16
+
17
+
getLocalVotes: (pollUri: string) => LocalVote[];
18
+
}
19
+
20
+
const PollMutationContext = createContext<PollMutationContextType | undefined>(undefined);
21
+
22
+
export function PollMutationQueueProvider({ children }: { children: React.ReactNode }) {
23
+
const { agent } = useAuth();
24
+
const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom);
25
+
26
+
// Helper to safely update state
27
+
const updateLocalState = useCallback((pollUri: string, updater: (prev: LocalVote[]) => LocalVote[]) => {
28
+
setLocalVotes(prev => ({
29
+
...prev,
30
+
[pollUri]: updater(prev[pollUri] || [])
31
+
}));
32
+
}, [setLocalVotes]);
33
+
34
+
const getLocalVotes = useCallback((pollUri: string) => {
35
+
return localVotes[pollUri] || [];
36
+
}, [localVotes]);
37
+
38
+
const castVote = useCallback(async (
39
+
pollUri: string,
40
+
pollCid: string,
41
+
option: string,
42
+
isMultiple: boolean,
43
+
currentServerVotes: string[] // Array of AT-URIs existing on server
44
+
) => {
45
+
if (!agent?.did) return;
46
+
47
+
const optionKey = option as 'a' | 'b' | 'c' | 'd';
48
+
const timestamp = Date.now();
49
+
50
+
// 1. DETERMINE ACTION: Are we adding or removing?
51
+
// Check local state first, then server state
52
+
const currentLocal = localVotes[pollUri] || [];
53
+
54
+
// Is this option currently selected in our "Merged" view?
55
+
// It's selected if it's in local state OR (in server state AND NOT specifically removed locally)
56
+
// For simplicity in this logic, we will assume if local state exists, it overrides server state for that option.
57
+
const isLocallySelected = currentLocal.find(v => v.option === optionKey);
58
+
59
+
// Logic: Toggle
60
+
if (isLocallySelected) {
61
+
// --- UNVOTE OPERATION ---
62
+
63
+
// 1. Optimistic Update: Remove from local state immediately
64
+
updateLocalState(pollUri, (prev) => prev.filter(v => v.option !== optionKey));
65
+
66
+
try {
67
+
// If it was 'confirmed' (has a URI) or was a server vote, we delete.
68
+
// If it was 'pending', we can't delete yet (complex edge case), strictly ideally we block interaction on pending.
69
+
70
+
let uriToDelete = isLocallySelected.uri;
71
+
72
+
// If local didn't have URI (rare race condition) check server votes
73
+
if (!uriToDelete) {
74
+
const serverMatch = currentServerVotes.find(v => v.includes(`app.reddwarf.poll.vote.${optionKey}`));
75
+
if (serverMatch) uriToDelete = serverMatch;
76
+
}
77
+
78
+
if (uriToDelete) {
79
+
const match = uriToDelete.match(/at:\/\/(.+)\/(.+)\/(.+)/);
80
+
if (match) {
81
+
const [, repo, collection, rkey] = match;
82
+
await agent.com.atproto.repo.deleteRecord({ repo, collection, rkey });
83
+
}
84
+
}
85
+
} catch (e) {
86
+
console.error("Failed to unvote", e);
87
+
renderSnack({ title: "Failed to remove vote" });
88
+
// Revert: add it back
89
+
updateLocalState(pollUri, (prev) => [...prev, isLocallySelected]);
90
+
}
91
+
92
+
} else {
93
+
// --- VOTE OPERATION ---
94
+
95
+
// 1. Optimistic Update: Add to local state
96
+
const tempVote: LocalVote = {
97
+
pollUri,
98
+
option: optionKey,
99
+
status: 'pending',
100
+
timestamp
101
+
};
102
+
103
+
updateLocalState(pollUri, (prev) => {
104
+
const newState = isMultiple ? [...prev] : []; // If single choice, clear other local votes
105
+
// Add new vote
106
+
newState.push(tempVote);
107
+
return newState;
108
+
});
109
+
110
+
// 2. Handle Single Choice - Network Side (Delete others)
111
+
if (!isMultiple) {
112
+
// We need to delete ANY existing votes (Server or Local Confirmed) that aren't this option
113
+
// Note: The UI updated instantly above, so the user sees the switch. Now we assume the debt.
114
+
const votesToDelete = [
115
+
...currentServerVotes,
116
+
...(localVotes[pollUri]?.map(v => v.uri).filter(Boolean) as string[] || [])
117
+
];
118
+
119
+
// Fire and forget deletions (or queue them)
120
+
votesToDelete.forEach(voteUri => {
121
+
if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; // Don't delete self (shouldn't happen here but safety)
122
+
const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
123
+
if (match) {
124
+
const [, repo, collection, rkey] = match;
125
+
agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error);
126
+
}
127
+
});
128
+
}
129
+
130
+
// 3. The 5-Second Grace Period Logic
131
+
let isTimedOut = false;
132
+
133
+
const timeoutPromise = new Promise<void>((resolve) => {
134
+
setTimeout(() => {
135
+
if (!isTimedOut) { // Check purely for closure capture
136
+
// We check the *current* state. If it is still pending, we revert visual.
137
+
// We access the ref/current state via the setter callback to be safe
138
+
setLocalVotes(current => {
139
+
const pollVotes = current[pollUri] || [];
140
+
const myVote = pollVotes.find(v => v.option === optionKey && v.timestamp === timestamp);
141
+
142
+
if (myVote && myVote.status === 'pending') {
143
+
isTimedOut = true;
144
+
// REVERT VISUALS (Requirement 1)
145
+
// We remove it from local state so the UI looks "unvoted", but the request continues.
146
+
return {
147
+
...current,
148
+
[pollUri]: pollVotes.filter(v => v !== myVote)
149
+
};
150
+
}
151
+
return current;
152
+
});
153
+
}
154
+
resolve();
155
+
}, 5000);
156
+
});
157
+
158
+
// 4. Perform Network Request
159
+
const performVote = async () => {
160
+
try {
161
+
const res = await agent.com.atproto.repo.createRecord({
162
+
collection: `app.reddwarf.poll.vote.${optionKey}`,
163
+
repo: agent.assertDid,
164
+
record: {
165
+
$type: `app.reddwarf.poll.vote.${optionKey}`,
166
+
subject: { uri: pollUri, cid: pollCid },
167
+
createdAt: new Date().toISOString(),
168
+
},
169
+
});
170
+
171
+
// SUCCESS!
172
+
173
+
// Requirement 2: Hold the URI.
174
+
// We force this into the state with status 'confirmed'.
175
+
// Even if we timed out earlier (and removed it), this puts it back!
176
+
updateLocalState(pollUri, (prev) => {
177
+
// Remove any pending entry for this option (if it exists)
178
+
const clean = prev.filter(v => v.option !== optionKey);
179
+
return [...clean, {
180
+
pollUri,
181
+
option: optionKey,
182
+
status: 'confirmed',
183
+
uri: res.data.uri,
184
+
timestamp: Date.now() // Update timestamp to fresh
185
+
}];
186
+
});
187
+
188
+
} catch (e) {
189
+
console.error("Vote failed", e);
190
+
if (!isTimedOut) {
191
+
renderSnack({ title: "Vote failed" });
192
+
// Revert optimistic state
193
+
updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp));
194
+
}
195
+
}
196
+
};
197
+
198
+
// Run them
199
+
// We don't await the timeout for the UI, but the timeout logic runs in parallel
200
+
performVote();
201
+
// We don't await performVote here to unblock UI, but the logic inside handles state updates
202
+
}
203
+
204
+
}, [agent, localVotes, updateLocalState, setLocalVotes]);
205
+
206
+
return (
207
+
<PollMutationContext value={{ castVote, getLocalVotes }}>
208
+
{children}
209
+
</PollMutationContext>
210
+
);
211
+
}
212
+
213
+
export function usePollMutationQueue() {
214
+
const context = use(PollMutationContext);
215
+
if (!context) throw new Error("Missing PollMutationQueueProvider");
216
+
return context;
217
+
}
218
+
219
+
export function usePollData(
220
+
pollUri: string,
221
+
isMultiple: boolean,
222
+
serverCounts: { a: number; b: number; c: number; d: number },
223
+
serverUserVotes: string[] // Array of AT-URIs (e.g. ['at://.../vote.a/...'])
224
+
) {
225
+
const { getLocalVotes } = usePollMutationQueue();
226
+
const localVotes = getLocalVotes(pollUri);
227
+
228
+
return useMemo(() => {
229
+
// 1. Identify which options the SERVER thinks we voted for
230
+
const serverState = {
231
+
a: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.a")),
232
+
b: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.b")),
233
+
c: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.c")),
234
+
d: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.d")),
235
+
};
236
+
237
+
// 2. Identify which options LOCAL STATE thinks we voted for
238
+
// (Pending or Confirmed Stale-While-Revalidate)
239
+
const localState = {
240
+
a: localVotes.some((v) => v.option === "a"),
241
+
b: localVotes.some((v) => v.option === "b"),
242
+
c: localVotes.some((v) => v.option === "c"),
243
+
d: localVotes.some((v) => v.option === "d"),
244
+
};
245
+
246
+
// 3. Determine if we have ANY local activity
247
+
// If this is Single Choice, and we have a local vote, strictly ignore server votes for other options.
248
+
const hasAnyLocalVote = localVotes.length > 0;
249
+
250
+
const calculateOptionState = (option: "a" | "b" | "c" | "d") => {
251
+
const isLocallyVoted = localState[option];
252
+
const isServerVoted = serverState[option];
253
+
254
+
// STATUS MERGE:
255
+
// If Single Choice: Local Vote overrides everything.
256
+
// If Multi Choice: Local Vote || Server Vote.
257
+
let hasVoted = isLocallyVoted;
258
+
259
+
if (!isMultiple) {
260
+
// Single Choice Logic:
261
+
// If we haven't touched this poll locally, trust the server.
262
+
// If we HAVE touched it locally (voted for X), ignore server's Y.
263
+
if (!hasAnyLocalVote && isServerVoted) {
264
+
hasVoted = true;
265
+
}
266
+
} else {
267
+
// Multi Choice Logic:
268
+
// Simple Union. (Note: Unvoting in multi-choice with your provider might flicker
269
+
// because unvoting deletes the local record, causing fall-through to server record.
270
+
// But adding votes works perfectly).
271
+
hasVoted = isLocallyVoted || isServerVoted;
272
+
}
273
+
274
+
// COUNT MERGE:
275
+
// Start with server count.
276
+
let count = serverCounts[option] || 0;
277
+
278
+
// If we show it as voted LOCALLY, but Server doesn't know yet -> Add 1
279
+
if (isLocallyVoted && !isServerVoted) {
280
+
count++;
281
+
}
282
+
283
+
// Edge Case: If we show it as NOT voted (because we switched to another option locally),
284
+
// but Server still counts it -> Subtract 1 (Visual only)
285
+
// This happens in single choice switching A -> B.
286
+
// We want to decrement A visually while incrementing B.
287
+
if (!isMultiple && hasAnyLocalVote && !isLocallyVoted && isServerVoted) {
288
+
count = Math.max(0, count - 1);
289
+
}
290
+
291
+
return { hasVoted, count };
292
+
};
293
+
294
+
const stateA = calculateOptionState("a");
295
+
const stateB = calculateOptionState("b");
296
+
const stateC = calculateOptionState("c");
297
+
const stateD = calculateOptionState("d");
298
+
299
+
return {
300
+
results: {
301
+
a: stateA,
302
+
b: stateB,
303
+
c: stateC,
304
+
d: stateD,
305
+
},
306
+
// Helper to check if user has interacted at all
307
+
hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted,
308
+
totalVotes: stateA.count + stateB.count + stateC.count + stateD.count
309
+
};
310
+
}, [localVotes, serverUserVotes, serverCounts, isMultiple]);
311
+
}
+17
-9
src/routes/__root.tsx
···
25
import { NotFound } from "~/components/NotFound";
26
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
0
28
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
29
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
30
import { seo } from "~/utils/seo";
···
83
return (
84
<UnifiedAuthProvider>
85
<LikeMutationQueueProvider>
86
-
<RootDocument>
87
-
<KeepAliveProvider>
88
-
<AppToaster />
89
-
<KeepAliveOutlet />
90
-
</KeepAliveProvider>
91
-
</RootDocument>
0
0
92
</LikeMutationQueueProvider>
93
</UnifiedAuthProvider>
94
);
···
176
</button>
177
</div>
178
) : null}
179
-
<button className=" ml-4"
0
180
onClick={() => {
181
sonnerToast.dismiss(id);
182
}}
···
232
? "notifications"
233
: isProfile
234
? "profile"
235
-
: isModeration
236
? "moderation"
237
: "home";
238
···
806
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
807
}
808
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
809
-
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
0
0
0
0
810
onClickCallbback={() =>
811
navigate({
812
to: "/settings",
···
25
import { NotFound } from "~/components/NotFound";
26
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
28
+
import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider";
29
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
30
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
31
import { seo } from "~/utils/seo";
···
84
return (
85
<UnifiedAuthProvider>
86
<LikeMutationQueueProvider>
87
+
<PollMutationQueueProvider>
88
+
<RootDocument>
89
+
<KeepAliveProvider>
90
+
<AppToaster />
91
+
<KeepAliveOutlet />
92
+
</KeepAliveProvider>
93
+
</RootDocument>
94
+
</PollMutationQueueProvider>
95
</LikeMutationQueueProvider>
96
</UnifiedAuthProvider>
97
);
···
179
</button>
180
</div>
181
) : null}
182
+
<button
183
+
className=" ml-4"
184
onClick={() => {
185
sonnerToast.dismiss(id);
186
}}
···
236
? "notifications"
237
: isProfile
238
? "profile"
239
+
: isModeration
240
? "moderation"
241
: "home";
242
···
810
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
811
}
812
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
813
+
active={
814
+
locationEnum === "settings" ||
815
+
locationEnum === "feeds" ||
816
+
locationEnum === "moderation"
817
+
}
818
onClickCallbback={() =>
819
navigate({
820
to: "/settings",
+18
src/utils/atoms.ts
···
153
"enableWafrnTextAtom",
154
false
155
);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
153
"enableWafrnTextAtom",
154
false
155
);
156
+
157
+
158
+
// polls state
159
+
160
+
export type PollVoteStatus = 'pending' | 'confirmed';
161
+
162
+
export interface LocalVote {
163
+
pollUri: string;
164
+
option: 'a' | 'b' | 'c' | 'd';
165
+
status: PollVoteStatus;
166
+
uri?: string; // The AT-URI. 'undefined' if pending
167
+
timestamp: number;
168
+
}
169
+
170
+
// Map: PollURI -> Array of Votes (because a user can vote for A and B in multi-choice)
171
+
export type PollStateMap = Record<string, LocalVote[]>;
172
+
173
+
export const localPollVotesAtom = atom<PollStateMap>({});