polls on atproto
pollz.waow.tech
atproto
zig
1import { Client, simpleFetchHandler } from "@atcute/client";
2import {
3 CompositeDidDocumentResolver,
4 CompositeHandleResolver,
5 DohJsonHandleResolver,
6 PlcDidDocumentResolver,
7 AtprotoWebDidDocumentResolver,
8 WellKnownHandleResolver,
9} from "@atcute/identity-resolver";
10import {
11 configureOAuth,
12 createAuthorizationUrl,
13 defaultIdentityResolver,
14 finalizeAuthorization,
15 getSession,
16 OAuthUserAgent,
17 deleteStoredSession,
18} from "@atcute/oauth-browser-client";
19
20export const POLL = "tech.waow.poll";
21export const VOTE = "tech.waow.vote";
22
23export const didDocumentResolver = new CompositeDidDocumentResolver({
24 methods: {
25 plc: new PlcDidDocumentResolver(),
26 web: new AtprotoWebDidDocumentResolver(),
27 },
28});
29
30export const handleResolver = new CompositeHandleResolver({
31 strategy: "dns-first",
32 methods: {
33 dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }),
34 http: new WellKnownHandleResolver(),
35 },
36});
37
38const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech";
39export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev";
40
41configureOAuth({
42 metadata: {
43 client_id: `${BASE_URL}/oauth-client-metadata.json`,
44 redirect_uri: `${BASE_URL}/`,
45 },
46 identityResolver: defaultIdentityResolver({
47 handleResolver,
48 didDocumentResolver,
49 }),
50});
51
52// state
53export let agent: OAuthUserAgent | null = null;
54export let currentDid: string | null = null;
55
56export const setAgent = (a: OAuthUserAgent | null) => { agent = a; };
57export const setCurrentDid = (did: string | null) => { currentDid = did; };
58
59export type Poll = {
60 uri: string;
61 repo: string;
62 rkey: string;
63 text: string;
64 options: string[];
65 createdAt: string;
66 voteCount?: number;
67};
68
69export const polls = new Map<string, Poll>();
70
71// oauth
72export const login = async (handle: string): Promise<void> => {
73 const url = await createAuthorizationUrl({
74 scope: `atproto repo:${POLL} repo:${VOTE}`,
75 target: { type: "account", identifier: handle },
76 });
77 location.assign(url);
78};
79
80export const logout = async (): Promise<void> => {
81 if (currentDid) {
82 await deleteStoredSession(currentDid as `did:${string}:${string}`);
83 localStorage.removeItem("lastDid");
84 }
85 agent = null;
86 currentDid = null;
87};
88
89export const handleCallback = async (): Promise<boolean> => {
90 const params = new URLSearchParams(location.hash.slice(1));
91 if (!params.has("state")) return false;
92
93 history.replaceState(null, "", "/");
94 const { session } = await finalizeAuthorization(params);
95 agent = new OAuthUserAgent(session);
96 currentDid = session.info.sub;
97 localStorage.setItem("lastDid", currentDid);
98 return true;
99};
100
101export const restoreSession = async (): Promise<void> => {
102 const lastDid = localStorage.getItem("lastDid");
103 if (!lastDid) return;
104
105 try {
106 const session = await getSession(lastDid as `did:${string}:${string}`);
107 agent = new OAuthUserAgent(session);
108 currentDid = session.info.sub;
109 } catch {
110 localStorage.removeItem("lastDid");
111 }
112};
113
114// backend api
115export const fetchPolls = async (): Promise<void> => {
116 const res = await fetch(`${BACKEND_URL}/api/polls`);
117 if (!res.ok) throw new Error("failed to fetch polls");
118
119 const backendPolls = await res.json() as Array<{
120 uri: string;
121 repo: string;
122 rkey: string;
123 text: string;
124 options: string[];
125 createdAt: string;
126 voteCount: number;
127 }>;
128
129 for (const p of backendPolls) {
130 const existing = polls.get(p.uri);
131 if (existing) {
132 existing.voteCount = p.voteCount;
133 } else {
134 polls.set(p.uri, {
135 uri: p.uri,
136 repo: p.repo,
137 rkey: p.rkey,
138 text: p.text,
139 options: p.options,
140 createdAt: p.createdAt,
141 voteCount: p.voteCount,
142 });
143 }
144 }
145};
146
147export const fetchPoll = async (uri: string) => {
148 const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`);
149 if (!res.ok) return null;
150 return res.json() as Promise<{
151 uri: string;
152 repo: string;
153 rkey: string;
154 text: string;
155 options: Array<{ text: string; count: number }>;
156 createdAt: string;
157 }>;
158};
159
160export const fetchVoters = async (pollUri: string) => {
161 const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`);
162 if (!res.ok) return [];
163 return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>;
164};
165
166// create poll
167export const createPoll = async (text: string, options: string[]): Promise<string | null> => {
168 if (!agent || !currentDid) return null;
169
170 const rpc = new Client({ handler: agent });
171 const res = await rpc.post("com.atproto.repo.createRecord", {
172 input: {
173 repo: currentDid,
174 collection: POLL,
175 record: { $type: POLL, text, options, createdAt: new Date().toISOString() },
176 },
177 });
178
179 if (!res.ok) throw new Error(res.data.error || "failed to create poll");
180
181 const rkey = res.data.uri.split("/").pop()!;
182 polls.set(res.data.uri, {
183 uri: res.data.uri,
184 repo: currentDid,
185 rkey,
186 text,
187 options,
188 createdAt: new Date().toISOString(),
189 });
190
191 return res.data.uri;
192};
193
194// vote - creates or updates vote record on user's PDS
195export const vote = async (pollUri: string, option: number): Promise<void> => {
196 if (!agent || !currentDid) throw new Error("not logged in");
197
198 const rpc = new Client({ handler: agent });
199
200 // check if we already have a vote on this poll
201 const existing = await rpc.get("com.atproto.repo.listRecords", {
202 params: { repo: currentDid, collection: VOTE, limit: 100 },
203 });
204
205 let existingRkey: string | null = null;
206 if (existing.ok) {
207 for (const record of existing.data.records) {
208 const val = record.value as { subject?: string };
209 if (val.subject === pollUri) {
210 existingRkey = record.uri.split("/").pop()!;
211 break;
212 }
213 }
214 }
215
216 if (existingRkey) {
217 // update existing vote
218 const res = await rpc.post("com.atproto.repo.putRecord", {
219 input: {
220 repo: currentDid,
221 collection: VOTE,
222 rkey: existingRkey,
223 record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() },
224 },
225 });
226 if (!res.ok) throw new Error(res.data.error || res.data.message || "vote update failed");
227 } else {
228 // create new vote
229 const res = await rpc.post("com.atproto.repo.createRecord", {
230 input: {
231 repo: currentDid,
232 collection: VOTE,
233 record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() },
234 },
235 });
236 if (!res.ok) throw new Error(res.data.error || res.data.message || "vote failed");
237 }
238};
239
240// resolve handle from DID
241const handleCache = new Map<string, string>();
242
243export const resolveHandle = async (did: string): Promise<string> => {
244 if (handleCache.has(did)) return handleCache.get(did)!;
245 try {
246 const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
247 if (res.ok) {
248 const data = await res.json();
249 if (data.handle) {
250 handleCache.set(did, data.handle);
251 return data.handle;
252 }
253 }
254 } catch {}
255 return did;
256};
257
258// fetch poll directly from PDS (fallback)
259export const fetchPollFromPDS = async (repo: string, rkey: string) => {
260 const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`);
261 const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined;
262 const pdsUrl = pds?.serviceEndpoint || "https://bsky.social";
263
264 const pdsClient = new Client({
265 handler: simpleFetchHandler({ service: pdsUrl }),
266 });
267
268 const res = await pdsClient.get("com.atproto.repo.getRecord", {
269 params: { repo, collection: POLL, rkey },
270 });
271
272 if (!res.ok) return null;
273
274 const rec = res.data.value as { text: string; options: string[]; createdAt: string };
275 return {
276 uri: res.data.uri,
277 repo,
278 rkey,
279 text: rec.text,
280 options: rec.options,
281 createdAt: rec.createdAt,
282 };
283};