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
Poll participant small view
whey.party
2 months ago
c647b290
56979364
+140
-80
1 changed file
expand all
collapse all
unified
split
src
components
UniversalPostRenderer.tsx
+140
-80
src/components/UniversalPostRenderer.tsx
···
1
1
import * as ATPAPI from "@atproto/api";
2
2
-
import { useQuery } from "@tanstack/react-query";
2
2
+
import { useQueryClient } from "@tanstack/react-query";
3
3
import { useNavigate } from "@tanstack/react-router";
4
4
import DOMPurify from "dompurify";
5
5
import { useAtom } from "jotai";
···
14
14
enableBridgyTextAtom,
15
15
enableWafrnTextAtom,
16
16
imgCDNAtom,
17
17
+
slingshotURLAtom,
17
18
} from "~/utils/atoms";
18
19
import { useGetOneToOneState } from "~/utils/followState";
19
20
import { useHydratedEmbed } from "~/utils/useHydrated";
20
21
import {
21
21
-
constructConstellationQuery,
22
22
useQueryArbitrary,
23
23
useQueryConstellation,
24
24
useQueryIdentity,
···
2152
2152
2153
2153
// Query vote counts for each option
2154
2154
const [constellationurl] = useAtom(constellationURLAtom);
2155
2155
+
const [imgcdn] = useAtom(imgCDNAtom);
2156
2156
+
const [slingshoturl] = useAtom(slingshotURLAtom);
2157
2157
+
const queryClient = useQueryClient();
2155
2158
2156
2159
const { data: voteCountsA } = useQueryConstellation({
2157
2160
method: "/links/count/distinct-dids",
···
2182
2185
});
2183
2186
2184
2187
// Query first page of voters for each option to get PFPs
2185
2185
-
const { data: votersA } = useQuery(
2186
2186
-
constructConstellationQuery({
2187
2187
-
constellation: constellationurl,
2188
2188
-
method: "/links",
2189
2189
-
target: pollUri,
2190
2190
-
collection: "app.reddwarf.poll.vote.a",
2191
2191
-
path: ".subject.uri",
2192
2192
-
}),
2193
2193
-
);
2188
2188
+
const { data: votersA } = useQueryConstellation({
2189
2189
+
method: "/links",
2190
2190
+
target: pollUri,
2191
2191
+
collection: "app.reddwarf.poll.vote.a",
2192
2192
+
path: ".subject.uri",
2193
2193
+
});
2194
2194
2195
2195
-
const { data: votersB } = useQuery(
2196
2196
-
constructConstellationQuery({
2197
2197
-
constellation: constellationurl,
2198
2198
-
method: "/links",
2199
2199
-
target: pollUri,
2200
2200
-
collection: "app.reddwarf.poll.vote.b",
2201
2201
-
path: ".subject.uri",
2202
2202
-
}),
2203
2203
-
);
2195
2195
+
const { data: votersB } = useQueryConstellation({
2196
2196
+
method: "/links",
2197
2197
+
target: pollUri,
2198
2198
+
collection: "app.reddwarf.poll.vote.b",
2199
2199
+
path: ".subject.uri",
2200
2200
+
});
2204
2201
2205
2205
-
const { data: votersC } = useQuery(
2206
2206
-
constructConstellationQuery({
2207
2207
-
constellation: constellationurl,
2208
2208
-
method: "/links",
2209
2209
-
target: pollUri,
2210
2210
-
collection: "app.reddwarf.poll.vote.c",
2211
2211
-
path: ".subject.uri",
2212
2212
-
}),
2213
2213
-
);
2202
2202
+
const { data: votersC } = useQueryConstellation({
2203
2203
+
method: "/links",
2204
2204
+
target: pollUri,
2205
2205
+
collection: "app.reddwarf.poll.vote.c",
2206
2206
+
path: ".subject.uri",
2207
2207
+
});
2214
2208
2215
2215
-
const { data: votersD } = useQuery(
2216
2216
-
constructConstellationQuery({
2217
2217
-
constellation: constellationurl,
2218
2218
-
method: "/links",
2219
2219
-
target: pollUri,
2220
2220
-
collection: "app.reddwarf.poll.vote.d",
2221
2221
-
path: ".subject.uri",
2222
2222
-
}),
2223
2223
-
);
2209
2209
+
const { data: votersD } = useQueryConstellation({
2210
2210
+
method: "/links",
2211
2211
+
target: pollUri,
2212
2212
+
collection: "app.reddwarf.poll.vote.d",
2213
2213
+
path: ".subject.uri",
2214
2214
+
});
2224
2215
2225
2216
// Check if user has already voted for each option in this poll
2226
2217
const userVotesA = useGetOneToOneState(
···
2267
2258
: undefined,
2268
2259
);
2269
2260
2270
2270
-
if (isLoading) {
2271
2271
-
return (
2272
2272
-
<div className="animate-pulse">
2273
2273
-
<div className="flex items-center gap-2 mb-3">
2274
2274
-
<div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div>
2275
2275
-
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
2276
2276
-
</div>
2277
2277
-
<div className="space-y-2">
2278
2278
-
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div>
2279
2279
-
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div>
2280
2280
-
</div>
2281
2281
-
</div>
2282
2282
-
);
2283
2283
-
}
2284
2261
2285
2285
-
if (error || !pollRecord?.value) {
2286
2286
-
return <div className="text-red-500 text-sm p-2">Failed to load poll</div>;
2287
2287
-
}
2288
2262
2289
2289
-
const poll = pollRecord.value as {
2263
2263
+
const poll = pollRecord?.value as {
2290
2264
a: string;
2291
2265
b: string;
2292
2266
c?: string;
···
2297
2271
};
2298
2272
2299
2273
const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean);
2300
2300
-
const isExpired = false //poll.expiry ? new Date(poll.expiry) < new Date() : false;
2301
2301
-
2302
2302
-
// todo unused waiting for private polls
2303
2303
-
// undefined for public polls which equals never expires
2304
2304
-
const formattedDate = undefined;
2305
2305
-
// const formattedDate = poll.expiry
2306
2306
-
// ? new Date(poll.expiry).toLocaleDateString("en-US", {
2307
2307
-
// month: "short",
2308
2308
-
// day: "numeric",
2309
2309
-
// hour: "numeric",
2310
2310
-
// minute: "2-digit",
2311
2311
-
// })
2312
2312
-
// : null;
2313
2274
2314
2275
// Calculate vote counts
2315
2276
const voteData = [
2316
2277
{
2317
2278
option: "a",
2318
2279
count: parseInt((voteCountsA as any)?.total || "0"),
2319
2319
-
voters: (votersA as any)?.linking_records || [],
2280
2280
+
voters: votersA?.linking_records || [],
2320
2281
},
2321
2282
{
2322
2283
option: "b",
2323
2284
count: parseInt((voteCountsB as any)?.total || "0"),
2324
2324
-
voters: (votersB as any)?.linking_records || [],
2285
2285
+
voters: votersB?.linking_records || [],
2325
2286
},
2326
2287
{
2327
2288
option: "c",
2328
2289
count: parseInt((voteCountsC as any)?.total || "0"),
2329
2329
-
voters: (votersC as any)?.linking_records || [],
2290
2290
+
voters: votersC?.linking_records || [],
2330
2291
},
2331
2292
{
2332
2293
option: "d",
2333
2294
count: parseInt((voteCountsD as any)?.total || "0"),
2334
2334
-
voters: (votersD as any)?.linking_records || [],
2295
2295
+
voters: votersD?.linking_records || [],
2335
2296
},
2336
2297
].slice(0, options.length);
2337
2298
2299
2299
+
if (isLoading) {
2300
2300
+
return (
2301
2301
+
<div className="animate-pulse">
2302
2302
+
<div className="flex items-center gap-2 mb-3">
2303
2303
+
<div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div>
2304
2304
+
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
2305
2305
+
</div>
2306
2306
+
<div className="space-y-2">
2307
2307
+
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div>
2308
2308
+
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div>
2309
2309
+
</div>
2310
2310
+
</div>
2311
2311
+
);
2312
2312
+
}
2313
2313
+
2314
2314
+
if (error || !pollRecord?.value) {
2315
2315
+
return <div className="text-red-500 text-sm p-2">Failed to load poll</div>;
2316
2316
+
}
2317
2317
+
const isExpired = false; //poll.expiry ? new Date(poll.expiry) < new Date() : false;
2318
2318
+
2319
2319
+
// todo unused waiting for private polls
2320
2320
+
// undefined for public polls which equals never expires
2321
2321
+
const formattedDate = undefined;
2322
2322
+
// const formattedDate = poll.expiry
2323
2323
+
// ? new Date(poll.expiry).toLocaleDateString("en-US", {
2324
2324
+
// month: "short",
2325
2325
+
// day: "numeric",
2326
2326
+
// hour: "numeric",
2327
2327
+
// minute: "2-digit",
2328
2328
+
// })
2329
2329
+
// : null;
2330
2330
+
2331
2331
+
2338
2332
const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0);
2339
2333
2340
2334
const handleVote = async (option: string) => {
···
2460
2454
}
2461
2455
})();
2462
2456
2457
2457
+
const rowData = voteData.find((v) => v.option === optionKey);
2463
2458
const hasVotedForOption =
2464
2459
userVotesForOption && userVotesForOption.length > 0;
2465
2460
const voteCount =
2466
2461
voteData.find((v) => v.option === optionKey)?.count ?? 0;
2467
2462
const votePercentage =
2468
2463
totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0;
2464
2464
+
2465
2465
+
// Extract just the DIDs we want to show (top 2)
2466
2466
+
const topVoters = rowData?.voters
2467
2467
+
.filter(v => !!v.did)
2468
2468
+
.slice(0, 2) || [];
2469
2469
2470
2470
return (
2471
2471
<div
···
2494
2494
)}
2495
2495
</span>
2496
2496
2497
2497
-
{/* Vote count */}
2498
2498
-
<span className="relative z-10 text-sm font-medium text-gray-600 dark:text-gray-400">
2499
2499
-
{votePercentage.toFixed(0)}%
2500
2500
-
</span>
2497
2497
+
{/* Avatar circles and vote count */}
2498
2498
+
<div className="relative z-10 flex items-center gap-2">
2499
2499
+
{/* Avatar circles - semi overlapping */}
2500
2500
+
2501
2501
+
{topVoters.length > 0 && (
2502
2502
+
<div className="flex -space-x-2">
2503
2503
+
{topVoters.map((voter, idx) => (
2504
2504
+
<div
2505
2505
+
key={voter.did} // Use DID as key, it's stable
2506
2506
+
className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200"
2507
2507
+
style={{ zIndex: 2 - idx }}
2508
2508
+
>
2509
2509
+
{/* The Component handles the async fetch! */}
2510
2510
+
<PollOptionAvatar
2511
2511
+
did={voter.did}
2512
2512
+
/>
2513
2513
+
</div>
2514
2514
+
))}
2515
2515
+
</div>
2516
2516
+
)}
2517
2517
+
2518
2518
+
{/* Vote count */}
2519
2519
+
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
2520
2520
+
{votePercentage.toFixed(0)}%
2521
2521
+
</span>
2522
2522
+
</div>
2501
2523
</div>
2502
2524
);
2503
2525
})}
···
2509
2531
<div className="flex items-center gap-2">
2510
2532
<IconMdiClockOutline />
2511
2533
{/* <span>Expires {formattedDate}</span> */}
2512
2512
-
{formattedDate ? !isExpired ? (
2513
2513
-
<span>Expires {formattedDate}</span>
2514
2514
-
) : (<span>Expired at {formattedDate}</span>) : <span>Never expires</span>}
2534
2534
+
{formattedDate ? (
2535
2535
+
!isExpired ? (
2536
2536
+
<span>Expires {formattedDate}</span>
2537
2537
+
) : (
2538
2538
+
<span>Expired at {formattedDate}</span>
2539
2539
+
)
2540
2540
+
) : (
2541
2541
+
<span>Never expires</span>
2542
2542
+
)}
2515
2543
</div>
2516
2544
2517
2545
{/* Status */}
···
2528
2556
</div>
2529
2557
</div>
2530
2558
</div>
2559
2559
+
);
2560
2560
+
}
2561
2561
+
2562
2562
+
function PollOptionAvatar({
2563
2563
+
did,
2564
2564
+
}: {
2565
2565
+
did: string;
2566
2566
+
}) {
2567
2567
+
const [imgcdn] = useAtom(imgCDNAtom);
2568
2568
+
// Each avatar handles its own data fetching
2569
2569
+
// If this specific DID is already in cache, it loads instantly
2570
2570
+
const { data: profileRecord } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`)
2571
2571
+
2572
2572
+
//const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record;
2573
2573
+
const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn);
2574
2574
+
2575
2575
+
if (!avatarUrl) {
2576
2576
+
// Fallback grey circle
2577
2577
+
return <div className="w-full h-full bg-gray-500" />;
2578
2578
+
}
2579
2579
+
2580
2580
+
return (
2581
2581
+
<img
2582
2582
+
src={avatarUrl}
2583
2583
+
alt="voter"
2584
2584
+
className="w-full h-full object-cover"
2585
2585
+
onError={(e) => {
2586
2586
+
const target = e.target as HTMLImageElement;
2587
2587
+
target.style.display = "none";
2588
2588
+
target.parentElement!.style.backgroundColor = "#6b7280";
2589
2589
+
}}
2590
2590
+
/>
2531
2591
);
2532
2592
}
2533
2593