polls on atproto pollz.waow.tech
atproto zig
at main 283 lines 8.1 kB view raw
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};