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