a tool for shared writing and social publishing

Feature/atp polls (#228)

* loosen some lexicons

* wip poll record

* add publication poll components

* enable optimistic votes and prompt to signin

* add toggle to see results for creator

* pass empty polldata array to preview components

* disable poll edit on read view of pub polls

* allow multiple poll options but only use the first for now

authored by awarm.space and committed by

GitHub 0fd0b80a 6f515d5a

+1371 -15
+65 -10
actions/publishToPublication.ts
··· 22 22 PubLeafletBlocksBlockquote, 23 23 PubLeafletBlocksIframe, 24 24 PubLeafletBlocksPage, 25 + PubLeafletBlocksPoll, 26 + PubLeafletPollDefinition, 25 27 } from "lexicons/api"; 26 28 import { Block } from "components/Blocks/Block"; 27 29 import { TID } from "@atproto/common"; ··· 79 81 facts, 80 82 agent, 81 83 root_entity, 84 + credentialSession.did!, 82 85 ); 83 86 84 87 let existingRecord = ··· 148 151 facts: Fact<any>[], 149 152 agent: AtpBaseClient, 150 153 root_entity: string, 154 + did: string, 151 155 ) { 152 156 let scan = scanIndexLocal(facts); 153 157 let pages: { ··· 161 165 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 162 166 if (!firstEntity) throw new Error("No root page"); 163 167 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 164 - let b = await blocksToRecord(blocks); 168 + let b = await blocksToRecord(blocks, did); 165 169 return { firstPageBlocks: b, pages }; 166 170 167 171 async function uploadImage(src: string) { ··· 175 179 } 176 180 async function blocksToRecord( 177 181 blocks: Block[], 182 + did: string, 178 183 ): Promise<PubLeafletPagesLinearDocument.Block[]> { 179 184 let parsedBlocks = parseBlocksToList(blocks); 180 185 return ( ··· 190 195 : alignmentValue === "right" 191 196 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 192 197 : undefined; 193 - let b = await blockToRecord(blockOrList.block); 198 + let b = await blockToRecord(blockOrList.block, did); 194 199 if (!b) return []; 195 200 let block: PubLeafletPagesLinearDocument.Block = { 196 201 $type: "pub.leaflet.pages.linearDocument#block", ··· 203 208 $type: "pub.leaflet.pages.linearDocument#block", 204 209 block: { 205 210 $type: "pub.leaflet.blocks.unorderedList", 206 - children: await childrenToRecord(blockOrList.children), 211 + children: await childrenToRecord(blockOrList.children, did), 207 212 }, 208 213 }; 209 214 return [block]; ··· 213 218 ).flat(); 214 219 } 215 220 216 - async function childrenToRecord(children: List[]) { 221 + async function childrenToRecord(children: List[], did: string) { 217 222 return ( 218 223 await Promise.all( 219 224 children.map(async (child) => { 220 - let content = await blockToRecord(child.block); 225 + let content = await blockToRecord(child.block, did); 221 226 if (!content) return []; 222 227 let record: PubLeafletBlocksUnorderedList.ListItem = { 223 228 $type: "pub.leaflet.blocks.unorderedList#listItem", 224 229 content, 225 - children: await childrenToRecord(child.children), 230 + children: await childrenToRecord(child.children, did), 226 231 }; 227 232 return record; 228 233 }), 229 234 ) 230 235 ).flat(); 231 236 } 232 - async function blockToRecord(b: Block) { 237 + async function blockToRecord(b: Block, did: string) { 233 238 const getBlockContent = (b: string) => { 234 239 let [content] = scan.eav(b, "block/text"); 235 240 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; ··· 247 252 let [pageType] = scan.eav(page.data.value, "page/type"); 248 253 249 254 if (pageType?.data.value === "canvas") { 250 - let canvasBlocks = await canvasBlocksToRecord(page.data.value); 255 + let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 251 256 pages.push({ 252 257 id: page.data.value, 253 258 blocks: canvasBlocks, ··· 257 262 let blocks = getBlocksWithTypeLocal(facts, page.data.value); 258 263 pages.push({ 259 264 id: page.data.value, 260 - blocks: await blocksToRecord(blocks), 265 + blocks: await blocksToRecord(blocks, did), 261 266 type: "doc", 262 267 }); 263 268 } ··· 386 391 }; 387 392 return block; 388 393 } 394 + if (b.type === "poll") { 395 + // Get poll options from the entity 396 + let pollOptions = scan.eav(b.value, "poll/options"); 397 + let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 398 + (opt) => { 399 + let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 400 + return { 401 + $type: "pub.leaflet.poll.definition#option", 402 + text: optionName?.data.value || "", 403 + }; 404 + }, 405 + ); 406 + 407 + // Create the poll definition record 408 + let pollRecord: PubLeafletPollDefinition.Record = { 409 + $type: "pub.leaflet.poll.definition", 410 + name: "Poll", // Default name, can be customized 411 + options, 412 + }; 413 + 414 + // Upload the poll record 415 + let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 416 + //use the entity id as the rkey so we can associate it in the editor 417 + rkey: b.value, 418 + repo: did, 419 + collection: pollRecord.$type, 420 + record: pollRecord, 421 + validate: false, 422 + }); 423 + 424 + // Optimistically write poll definition to database 425 + console.log( 426 + await supabaseServerClient.from("atp_poll_records").upsert({ 427 + uri: pollResult.uri, 428 + cid: pollResult.cid, 429 + record: pollRecord as Json, 430 + }), 431 + ); 432 + 433 + // Return a poll block with reference to the poll record 434 + let block: $Typed<PubLeafletBlocksPoll.Main> = { 435 + $type: "pub.leaflet.blocks.poll", 436 + pollRef: { 437 + uri: pollResult.uri, 438 + cid: pollResult.cid, 439 + }, 440 + }; 441 + return block; 442 + } 389 443 return; 390 444 } 391 445 392 446 async function canvasBlocksToRecord( 393 447 pageID: string, 448 + did: string, 394 449 ): Promise<PubLeafletPagesCanvas.Block[]> { 395 450 let canvasBlocks = scan.eav(pageID, "canvas/block"); 396 451 return ( ··· 411 466 factID: canvasBlock.id, 412 467 }; 413 468 414 - let content = await blockToRecord(block); 469 + let content = await blockToRecord(block, did); 415 470 if (!content) return null; 416 471 417 472 // Get canvas-specific properties
+10
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 20 20 import { InfoSmall } from "components/Icons/InfoSmall"; 21 21 import { PostHeader } from "./PostHeader/PostHeader"; 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PollData } from "./fetchPollData"; 23 24 24 25 export function CanvasPage({ 25 26 document, ··· 30 31 pubRecord, 31 32 prerenderedCodeBlocks, 32 33 bskyPostData, 34 + pollData, 33 35 document_uri, 34 36 pageId, 35 37 pageOptions, ··· 44 46 did: string; 45 47 prerenderedCodeBlocks?: Map<string, string>; 46 48 bskyPostData: AppBskyFeedDefs.PostView[]; 49 + pollData: PollData[]; 47 50 preferences: { showComments?: boolean }; 48 51 pageId?: string; 49 52 pageOptions?: React.ReactNode; ··· 79 82 did={did} 80 83 prerenderedCodeBlocks={prerenderedCodeBlocks} 81 84 bskyPostData={bskyPostData} 85 + pollData={pollData} 82 86 pageId={pageId} 83 87 pages={pages} 84 88 /> ··· 92 96 prerenderedCodeBlocks, 93 97 bskyPostData, 94 98 pageId, 99 + pollData, 95 100 pages, 96 101 }: { 97 102 blocks: PubLeafletPagesCanvas.Block[]; 98 103 did: string; 99 104 prerenderedCodeBlocks?: Map<string, string>; 105 + pollData: PollData[]; 100 106 bskyPostData: AppBskyFeedDefs.PostView[]; 101 107 pageId?: string; 102 108 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; ··· 127 133 key={index} 128 134 canvasBlock={canvasBlock} 129 135 did={did} 136 + pollData={pollData} 130 137 prerenderedCodeBlocks={prerenderedCodeBlocks} 131 138 bskyPostData={bskyPostData} 132 139 pageId={pageId} ··· 145 152 did, 146 153 prerenderedCodeBlocks, 147 154 bskyPostData, 155 + pollData, 148 156 pageId, 149 157 pages, 150 158 index, ··· 153 161 did: string; 154 162 prerenderedCodeBlocks?: Map<string, string>; 155 163 bskyPostData: AppBskyFeedDefs.PostView[]; 164 + pollData: PollData[]; 156 165 pageId?: string; 157 166 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 158 167 index: number; ··· 178 187 > 179 188 <div className="contents"> 180 189 <Block 190 + pollData={pollData} 181 191 pageId={pageId} 182 192 pages={pages} 183 193 bskyPostData={bskyPostData}
+1
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 140 140 > 141 141 <div className="italic border border-border-light rounded-md px-2 pt-1"> 142 142 <PostContent 143 + pollData={[]} 143 144 pages={[]} 144 145 bskyPostData={[]} 145 146 blocks={content}
+4
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PageWrapper } from "components/Pages/Page"; 24 24 import { decodeQuotePosition } from "./quotePosition"; 25 + import { PollData } from "./fetchPollData"; 25 26 26 27 export function LinearDocumentPage({ 27 28 document, ··· 35 36 document_uri, 36 37 pageId, 37 38 pageOptions, 39 + pollData, 38 40 fullPageScroll, 39 41 }: { 40 42 document_uri: string; ··· 45 47 did: string; 46 48 prerenderedCodeBlocks?: Map<string, string>; 47 49 bskyPostData: AppBskyFeedDefs.PostView[]; 50 + pollData: PollData[]; 48 51 preferences: { showComments?: boolean }; 49 52 pageId?: string; 50 53 pageOptions?: React.ReactNode; ··· 81 84 /> 82 85 )} 83 86 <PostContent 87 + pollData={pollData} 84 88 pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 85 89 pageId={pageId} 86 90 bskyPostData={bskyPostData}
+24
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 15 15 PubLeafletBlocksBskyPost, 16 16 PubLeafletBlocksIframe, 17 17 PubLeafletBlocksPage, 18 + PubLeafletBlocksPoll, 18 19 } from "lexicons/api"; 19 20 20 21 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 29 30 import { openPage } from "./PostPages"; 30 31 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 31 32 import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 33 + import { PublishedPollBlock } from "./PublishedPollBlock"; 34 + import { PollData } from "./fetchPollData"; 32 35 33 36 export function PostContent({ 34 37 blocks, ··· 39 42 bskyPostData, 40 43 pageId, 41 44 pages, 45 + pollData, 42 46 }: { 43 47 blocks: PubLeafletPagesLinearDocument.Block[]; 44 48 pageId?: string; ··· 47 51 className?: string; 48 52 prerenderedCodeBlocks?: Map<string, string>; 49 53 bskyPostData: AppBskyFeedDefs.PostView[]; 54 + pollData: PollData[]; 50 55 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 51 56 }) { 52 57 return ( ··· 67 72 index={[index]} 68 73 preview={preview} 69 74 prerenderedCodeBlocks={prerenderedCodeBlocks} 75 + pollData={pollData} 70 76 /> 71 77 ); 72 78 })} ··· 85 91 bskyPostData, 86 92 pageId, 87 93 pages, 94 + pollData, 88 95 }: { 89 96 pageId?: string; 90 97 preview?: boolean; ··· 96 103 previousBlock?: PubLeafletPagesLinearDocument.Block; 97 104 prerenderedCodeBlocks?: Map<string, string>; 98 105 bskyPostData: AppBskyFeedDefs.PostView[]; 106 + pollData: PollData[]; 99 107 }) => { 100 108 let b = block; 101 109 let blockProps = { ··· 174 182 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 175 183 return <hr className="my-2 w-full border-border-light" />; 176 184 } 185 + case PubLeafletBlocksPoll.isMain(b.block): { 186 + let { cid, uri } = b.block.pollRef; 187 + const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); 188 + if (!pollVoteData) return null; 189 + return ( 190 + <PublishedPollBlock 191 + block={b.block} 192 + className={className} 193 + pollData={pollVoteData} 194 + /> 195 + ); 196 + } 177 197 case PubLeafletBlocksUnorderedList.isMain(b.block): { 178 198 return ( 179 199 <ul className="-ml-px sm:ml-[9px] pb-2"> 180 200 {b.block.children.map((child, i) => ( 181 201 <ListItem 202 + pollData={pollData} 182 203 pages={pages} 183 204 bskyPostData={bskyPostData} 184 205 index={[...index, i]} ··· 365 386 did: string; 366 387 className?: string; 367 388 bskyPostData: AppBskyFeedDefs.PostView[]; 389 + pollData: PollData[]; 368 390 pageId?: string; 369 391 }) { 370 392 let children = props.item.children?.length ? ( ··· 372 394 {props.item.children.map((child, index) => ( 373 395 <ListItem 374 396 pages={props.pages} 397 + pollData={props.pollData} 375 398 bskyPostData={props.bskyPostData} 376 399 index={[...props.index, index]} 377 400 item={child} ··· 390 413 /> 391 414 <div className="flex flex-col w-full"> 392 415 <Block 416 + pollData={props.pollData} 393 417 pages={props.pages} 394 418 bskyPostData={props.bskyPostData} 395 419 block={{ block: props.item.content }}
+6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 21 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 22 import { useParams } from "next/navigation"; 23 23 import { decodeQuotePosition } from "./quotePosition"; 24 + import { PollData } from "./fetchPollData"; 24 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 25 26 import { CanvasPage } from "./CanvasPage"; 26 27 ··· 107 108 prerenderedCodeBlocks, 108 109 bskyPostData, 109 110 document_uri, 111 + pollData, 110 112 }: { 111 113 document_uri: string; 112 114 document: PostPageData; ··· 117 119 prerenderedCodeBlocks?: Map<string, string>; 118 120 bskyPostData: AppBskyFeedDefs.PostView[]; 119 121 preferences: { showComments?: boolean }; 122 + pollData: PollData[]; 120 123 }) { 121 124 let drawer = useDrawerOpen(document_uri); 122 125 useInitializeOpenPages(); ··· 137 140 did={did} 138 141 profile={profile} 139 142 fullPageScroll={fullPageScroll} 143 + pollData={pollData} 140 144 preferences={preferences} 141 145 pubRecord={pubRecord} 142 146 prerenderedCodeBlocks={prerenderedCodeBlocks} ··· 186 190 profile={profile} 187 191 pubRecord={pubRecord} 188 192 prerenderedCodeBlocks={prerenderedCodeBlocks} 193 + pollData={pollData} 189 194 bskyPostData={bskyPostData} 190 195 document_uri={document_uri} 191 196 pageId={page.id} ··· 205 210 did={did} 206 211 preferences={preferences} 207 212 pubRecord={pubRecord} 213 + pollData={pollData} 208 214 prerenderedCodeBlocks={prerenderedCodeBlocks} 209 215 bskyPostData={bskyPostData} 210 216 document_uri={document_uri}
+2
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 178 178 /> 179 179 )} 180 180 <PostContent 181 + pollData={[]} 181 182 pages={[]} 182 183 did={props.did} 183 184 blocks={props.blocks} ··· 311 312 > 312 313 <div className="contents"> 313 314 <Block 315 + pollData={[]} 314 316 pageId={props.pageId} 315 317 pages={props.pages} 316 318 bskyPostData={props.bskyPostData}
+278
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 + "use client"; 2 + 3 + import { PubLeafletBlocksPoll, PubLeafletPollDefinition, PubLeafletPollVote } from "lexicons/api"; 4 + import { useState, useEffect } from "react"; 5 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 6 + import { useIdentityData } from "components/IdentityProvider"; 7 + import { AtpAgent } from "@atproto/api"; 8 + import { voteOnPublishedPoll } from "./voteOnPublishedPoll"; 9 + import { PollData } from "./fetchPollData"; 10 + import { Popover } from "components/Popover"; 11 + import LoginForm from "app/login/LoginForm"; 12 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 13 + 14 + // Helper function to extract the first option from a vote record 15 + const getVoteOption = (voteRecord: any): string | null => { 16 + try { 17 + const record = voteRecord as PubLeafletPollVote.Record; 18 + return record.option && record.option.length > 0 ? record.option[0] : null; 19 + } catch { 20 + return null; 21 + } 22 + }; 23 + 24 + export const PublishedPollBlock = (props: { 25 + block: PubLeafletBlocksPoll.Main; 26 + pollData: PollData; 27 + className?: string; 28 + }) => { 29 + const { identity } = useIdentityData(); 30 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 31 + const [isVoting, setIsVoting] = useState(false); 32 + const [showResults, setShowResults] = useState(false); 33 + const [optimisticVote, setOptimisticVote] = useState<{ 34 + option: string; 35 + voter_did: string; 36 + } | null>(null); 37 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 38 + let [isClient, setIsClient] = useState(false); 39 + useEffect(() => { 40 + setIsClient(true); 41 + }, []); 42 + 43 + const handleVote = async () => { 44 + if (!selectedOption || !identity?.atp_did) return; 45 + 46 + setIsVoting(true); 47 + 48 + // Optimistically add the vote 49 + setOptimisticVote({ 50 + option: selectedOption, 51 + voter_did: identity.atp_did, 52 + }); 53 + setShowResults(true); 54 + 55 + try { 56 + const result = await voteOnPublishedPoll( 57 + props.block.pollRef.uri, 58 + props.block.pollRef.cid, 59 + selectedOption, 60 + ); 61 + 62 + if (!result.success) { 63 + console.error("Failed to vote:", result.error); 64 + // Revert optimistic update on failure 65 + setOptimisticVote(null); 66 + setShowResults(false); 67 + } 68 + } catch (error) { 69 + console.error("Failed to vote:", error); 70 + // Revert optimistic update on failure 71 + setOptimisticVote(null); 72 + setShowResults(false); 73 + } finally { 74 + setIsVoting(false); 75 + } 76 + }; 77 + 78 + const hasVoted = 79 + !!identity?.atp_did && 80 + (!!props.pollData?.atp_poll_votes.find( 81 + (v) => v.voter_did === identity?.atp_did, 82 + ) || 83 + !!optimisticVote); 84 + let isCreator = 85 + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 86 + const displayResults = showResults || hasVoted; 87 + 88 + return ( 89 + <div 90 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 91 + style={{ 92 + backgroundColor: 93 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 94 + }} 95 + > 96 + {displayResults ? ( 97 + <> 98 + <PollResults 99 + pollData={props.pollData} 100 + hasVoted={hasVoted} 101 + setShowResults={setShowResults} 102 + optimisticVote={optimisticVote} 103 + /> 104 + {isCreator && !hasVoted && ( 105 + <div className="flex justify-start"> 106 + <button 107 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 108 + onClick={() => setShowResults(false)} 109 + > 110 + Back to Voting 111 + </button> 112 + </div> 113 + )} 114 + </> 115 + ) : ( 116 + <> 117 + {pollRecord.options.map((option, index) => ( 118 + <PollOptionButton 119 + key={index} 120 + option={option} 121 + optionIndex={index.toString()} 122 + selected={selectedOption === index.toString()} 123 + onSelect={() => setSelectedOption(index.toString())} 124 + disabled={!identity?.atp_did} 125 + /> 126 + ))} 127 + <div className="flex justify-between items-center"> 128 + <div className="flex justify-end gap-2"> 129 + {isCreator && ( 130 + <button 131 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 132 + onClick={() => setShowResults(!showResults)} 133 + > 134 + See Results 135 + </button> 136 + )} 137 + </div> 138 + {identity?.atp_did ? ( 139 + <ButtonPrimary 140 + className="place-self-end" 141 + onClick={handleVote} 142 + disabled={!selectedOption || isVoting} 143 + > 144 + {isVoting ? "Voting..." : "Vote!"} 145 + </ButtonPrimary> 146 + ) : ( 147 + <Popover 148 + asChild 149 + trigger={ 150 + <ButtonPrimary className="place-self-center"> 151 + <BlueskyTiny /> Login to vote 152 + </ButtonPrimary> 153 + } 154 + > 155 + {isClient && ( 156 + <LoginForm 157 + text="Log in to vote on this poll!" 158 + noEmail 159 + redirectRoute={window?.location.href + "?refreshAuth"} 160 + /> 161 + )} 162 + </Popover> 163 + )} 164 + </div> 165 + </> 166 + )} 167 + </div> 168 + ); 169 + }; 170 + 171 + const PollOptionButton = (props: { 172 + option: PubLeafletPollDefinition.Option; 173 + optionIndex: string; 174 + selected: boolean; 175 + onSelect: () => void; 176 + disabled?: boolean; 177 + }) => { 178 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 179 + 180 + return ( 181 + <div className="flex gap-2 items-center"> 182 + <ButtonComponent 183 + className="pollOption grow max-w-full flex" 184 + onClick={props.onSelect} 185 + disabled={props.disabled} 186 + > 187 + {props.option.text} 188 + </ButtonComponent> 189 + </div> 190 + ); 191 + }; 192 + 193 + const PollResults = (props: { 194 + pollData: PollData; 195 + hasVoted: boolean; 196 + setShowResults: (show: boolean) => void; 197 + optimisticVote: { option: string; voter_did: string } | null; 198 + }) => { 199 + // Merge optimistic vote with actual votes 200 + const allVotes = props.optimisticVote 201 + ? [ 202 + ...props.pollData.atp_poll_votes, 203 + { 204 + voter_did: props.optimisticVote.voter_did, 205 + record: { 206 + $type: "pub.leaflet.poll.vote", 207 + option: [props.optimisticVote.option], 208 + }, 209 + }, 210 + ] 211 + : props.pollData.atp_poll_votes; 212 + 213 + const totalVotes = allVotes.length || 0; 214 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 215 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 216 + ...o, 217 + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 218 + })); 219 + 220 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 221 + return ( 222 + <> 223 + {pollRecord.options.map((option, index) => { 224 + const votes = allVotes.filter( 225 + (v) => getVoteOption(v.record) === index.toString(), 226 + ).length; 227 + const isWinner = totalVotes > 0 && votes === highestVotes; 228 + 229 + return ( 230 + <PollResult 231 + key={index} 232 + option={option} 233 + votes={votes} 234 + totalVotes={totalVotes} 235 + winner={isWinner} 236 + /> 237 + ); 238 + })} 239 + </> 240 + ); 241 + }; 242 + 243 + const PollResult = (props: { 244 + option: PubLeafletPollDefinition.Option; 245 + votes: number; 246 + totalVotes: number; 247 + winner: boolean; 248 + }) => { 249 + return ( 250 + <div 251 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 252 + > 253 + <div 254 + style={{ 255 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 256 + paintOrder: "stroke fill", 257 + }} 258 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 259 + > 260 + <div className="grow max-w-full truncate">{props.option.text}</div> 261 + <div>{props.votes}</div> 262 + </div> 263 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 264 + <div 265 + className="bg-accent-contrast rounded-[2px] m-0.5" 266 + style={{ 267 + maskImage: "var(--hatchSVG)", 268 + maskRepeat: "repeat repeat", 269 + ...(props.votes === 0 270 + ? { width: "4px" } 271 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 272 + }} 273 + /> 274 + <div /> 275 + </div> 276 + </div> 277 + ); 278 + };
+25
app/lish/[did]/[publication]/[rkey]/fetchPollData.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { Json } from "supabase/database.types"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + 7 + export type PollData = { 8 + uri: string; 9 + cid: string; 10 + record: Json; 11 + atp_poll_votes: { record: Json; voter_did: string }[]; 12 + }; 13 + 14 + export async function fetchPollData(pollUris: string[]): Promise<PollData[]> { 15 + // Get current user's identity to check if they've voted 16 + const identity = await getIdentityData(); 17 + const userDid = identity?.atp_did; 18 + 19 + const { data } = await supabaseServerClient 20 + .from("atp_poll_records") 21 + .select(`*, atp_poll_votes(*)`) 22 + .in("uri", pollUris); 23 + 24 + return data || []; 25 + }
+12
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 20 20 import { PostPages } from "./PostPages"; 21 21 import { extractCodeBlocks } from "./extractCodeBlocks"; 22 22 import { LeafletLayout } from "components/LeafletLayout"; 23 + import { fetchPollData } from "./fetchPollData"; 23 24 24 25 export async function generateMetadata(props: { 25 26 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 115 116 { headers: {} }, 116 117 ) 117 118 : { data: { posts: [] } }; 119 + 120 + // Extract poll blocks and fetch vote data 121 + let pollBlocks = record.pages.flatMap((p) => { 122 + let page = p as PubLeafletPagesLinearDocument.Main; 123 + return page.blocks?.filter( 124 + (b) => b.block.$type === ids.PubLeafletBlocksPoll, 125 + ) || []; 126 + }); 127 + let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri)); 128 + 118 129 let firstPage = record.pages[0]; 119 130 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 120 131 if (PubLeafletPagesLinearDocument.isMain(firstPage)) { ··· 165 176 did={did} 166 177 blocks={blocks} 167 178 prerenderedCodeBlocks={prerenderedCodeBlocks} 179 + pollData={pollData} 168 180 /> 169 181 </LeafletLayout> 170 182
+64
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 + "use server"; 2 + 3 + import { createOauthClient } from "src/atproto-oauth"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { AtpBaseClient, AtUri } from "@atproto/api"; 6 + import { PubLeafletPollVote } from "lexicons/api"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { Json } from "supabase/database.types"; 9 + import { TID } from "@atproto/common"; 10 + 11 + export async function voteOnPublishedPoll( 12 + pollUri: string, 13 + pollCid: string, 14 + selectedOption: string, 15 + ): Promise<{ success: boolean; error?: string }> { 16 + try { 17 + const identity = await getIdentityData(); 18 + 19 + if (!identity?.atp_did) { 20 + return { success: false, error: "Not authenticated" }; 21 + } 22 + 23 + const oauthClient = await createOauthClient(); 24 + const session = await oauthClient.restore(identity.atp_did); 25 + let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 + 27 + const voteRecord: PubLeafletPollVote.Record = { 28 + $type: "pub.leaflet.poll.vote", 29 + poll: { 30 + uri: pollUri, 31 + cid: pollCid, 32 + }, 33 + option: [selectedOption], 34 + }; 35 + 36 + const rkey = TID.nextStr(); 37 + const voteUri = AtUri.make(identity.atp_did, "pub.leaflet.poll.vote", rkey); 38 + 39 + // Write to database optimistically before creating the record 40 + await supabaseServerClient.from("atp_poll_votes").upsert({ 41 + uri: voteUri.toString(), 42 + voter_did: identity.atp_did, 43 + poll_uri: pollUri, 44 + poll_cid: pollCid, 45 + record: voteRecord as unknown as Json, 46 + }); 47 + 48 + // Create the record on ATP 49 + await agent.com.atproto.repo.createRecord({ 50 + repo: identity.atp_did, 51 + collection: "pub.leaflet.poll.vote", 52 + rkey, 53 + record: voteRecord, 54 + }); 55 + 56 + return { success: true }; 57 + } catch (error) { 58 + console.error("Failed to vote:", error); 59 + return { 60 + success: false, 61 + error: error instanceof Error ? error.message : "Failed to vote", 62 + }; 63 + } 64 + }
+41
appview/index.ts
··· 9 9 PubLeafletGraphSubscription, 10 10 PubLeafletPublication, 11 11 PubLeafletComment, 12 + PubLeafletPollVote, 13 + PubLeafletPollDefinition, 12 14 } from "lexicons/api"; 13 15 import { 14 16 AppBskyEmbedExternal, ··· 44 46 ids.PubLeafletPublication, 45 47 ids.PubLeafletGraphSubscription, 46 48 ids.PubLeafletComment, 49 + ids.PubLeafletPollVote, 50 + ids.PubLeafletPollDefinition, 47 51 // ids.AppBskyActorProfile, 48 52 "app.bsky.feed.post", 49 53 ], ··· 165 169 if (evt.event === "delete") { 166 170 await supabase 167 171 .from("comments_on_documents") 172 + .delete() 173 + .eq("uri", evt.uri.toString()); 174 + } 175 + } 176 + if (evt.collection === ids.PubLeafletPollVote) { 177 + if (evt.event === "create" || evt.event === "update") { 178 + let record = PubLeafletPollVote.validateRecord(evt.record); 179 + if (!record.success) return; 180 + let { error } = await supabase.from("atp_poll_votes").upsert({ 181 + uri: evt.uri.toString(), 182 + voter_did: evt.did, 183 + poll_uri: record.value.poll.uri, 184 + poll_cid: record.value.poll.cid, 185 + record: record.value as Json, 186 + }); 187 + } 188 + if (evt.event === "delete") { 189 + await supabase 190 + .from("atp_poll_votes") 191 + .delete() 192 + .eq("uri", evt.uri.toString()); 193 + } 194 + } 195 + if (evt.collection === ids.PubLeafletPollDefinition) { 196 + if (evt.event === "create" || evt.event === "update") { 197 + let record = PubLeafletPollDefinition.validateRecord(evt.record); 198 + if (!record.success) return; 199 + let { error } = await supabase.from("atp_poll_records").upsert({ 200 + uri: evt.uri.toString(), 201 + cid: evt.cid.toString(), 202 + record: record.value as Json, 203 + }); 204 + if (error) console.log("Error upserting poll definition:", error); 205 + } 206 + if (evt.event === "delete") { 207 + await supabase 208 + .from("atp_poll_records") 168 209 .delete() 169 210 .eq("uri", evt.uri.toString()); 170 211 }
-1
components/Blocks/BlockCommands.tsx
··· 235 235 name: "Poll", 236 236 icon: <BlockPollSmall />, 237 237 type: "block", 238 - hiddenInPublication: true, 239 238 onSelect: async (rep, props, um) => { 240 239 let entity = await createBlockWithType(rep, props, "poll"); 241 240 let pollOptionEntity = v7();
+12 -1
components/Blocks/PollBlock.tsx
··· 8 8 import { theme } from "tailwind.config"; 9 9 import { useEntity, useReplicache } from "src/replicache"; 10 10 import { v7 } from "uuid"; 11 - import { usePollData } from "components/PageSWRDataProvider"; 11 + import { 12 + useLeafletPublicationData, 13 + usePollData, 14 + } from "components/PageSWRDataProvider"; 12 15 import { voteOnPoll } from "actions/pollActions"; 13 16 import { create } from "zustand"; 14 17 import { elementId } from "src/utils/elementId"; 15 18 import { CheckTiny } from "components/Icons/CheckTiny"; 16 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 + import { PublicationPollBlock } from "./PublicationPollBlock"; 17 21 18 22 export let usePollBlockUIState = create( 19 23 () => ··· 21 25 [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 22 26 }, 23 27 ); 28 + 24 29 export const PollBlock = (props: BlockProps) => { 30 + let { data: pub } = useLeafletPublicationData(); 31 + if (!pub) return <LeafletPollBlock {...props} />; 32 + return <PublicationPollBlock {...props} />; 33 + }; 34 + 35 + export const LeafletPollBlock = (props: BlockProps) => { 25 36 let isSelected = useUIState((s) => 26 37 s.selectedBlocks.find((b) => b.value === props.entityID), 27 38 );
+187
components/Blocks/PublicationPollBlock.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "./Block"; 3 + import { useMemo } from "react"; 4 + import { focusElement, AsyncValueInput } from "components/Input"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { useEntity, useReplicache } from "src/replicache"; 7 + import { v7 } from "uuid"; 8 + import { elementId } from "src/utils/elementId"; 9 + import { CloseTiny } from "components/Icons/CloseTiny"; 10 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 11 + import { 12 + PubLeafletBlocksPoll, 13 + PubLeafletDocument, 14 + PubLeafletPagesLinearDocument, 15 + } from "lexicons/api"; 16 + import { ids } from "lexicons/api/lexicons"; 17 + 18 + /** 19 + * PublicationPollBlock is used for editing polls in publication documents. 20 + * It allows adding/editing options when the poll hasn't been published yet, 21 + * but disables adding new options once the poll record exists (indicated by pollUri). 22 + */ 23 + export const PublicationPollBlock = (props: BlockProps) => { 24 + let { data: publicationData } = useLeafletPublicationData(); 25 + let isSelected = useUIState((s) => 26 + s.selectedBlocks.find((b) => b.value === props.entityID), 27 + ); 28 + // Check if this poll has been published in a publication document 29 + const isPublished = useMemo(() => { 30 + if (!publicationData?.documents?.data) return false; 31 + 32 + const docRecord = publicationData.documents 33 + .data as PubLeafletDocument.Record; 34 + console.log(docRecord); 35 + 36 + // Search through all pages and blocks to find if this poll entity has been published 37 + for (const page of docRecord.pages || []) { 38 + if (page.$type === "pub.leaflet.pages.linearDocument") { 39 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 + for (const blockWrapper of linearPage.blocks || []) { 41 + if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) { 42 + const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main; 43 + console.log(pollBlock); 44 + // Check if this poll's rkey matches our entity ID 45 + const rkey = pollBlock.pollRef.uri.split("/").pop(); 46 + if (rkey === props.entityID) { 47 + return true; 48 + } 49 + } 50 + } 51 + } 52 + } 53 + return false; 54 + }, [publicationData, props.entityID]); 55 + 56 + return ( 57 + <div 58 + className={`poll flex flex-col gap-2 p-3 w-full 59 + ${isSelected ? "block-border-selected " : "block-border"}`} 60 + style={{ 61 + backgroundColor: 62 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 63 + }} 64 + > 65 + <EditPollForPublication 66 + entityID={props.entityID} 67 + isPublished={isPublished} 68 + /> 69 + </div> 70 + ); 71 + }; 72 + 73 + const EditPollForPublication = (props: { 74 + entityID: string; 75 + isPublished: boolean; 76 + }) => { 77 + let pollOptions = useEntity(props.entityID, "poll/options"); 78 + let { rep } = useReplicache(); 79 + let permission_set = useEntitySetContext(); 80 + 81 + return ( 82 + <> 83 + {props.isPublished && ( 84 + <div className="text-sm italic text-tertiary"> 85 + This poll has been published. You can't edit the options. 86 + </div> 87 + )} 88 + 89 + {pollOptions.length === 0 && !props.isPublished && ( 90 + <div className="text-center italic text-tertiary text-sm"> 91 + no options yet... 92 + </div> 93 + )} 94 + 95 + {pollOptions.map((p) => ( 96 + <EditPollOptionForPublication 97 + key={p.id} 98 + entityID={p.data.value} 99 + pollEntity={props.entityID} 100 + disabled={props.isPublished} 101 + canDelete={!props.isPublished} 102 + /> 103 + ))} 104 + 105 + {!props.isPublished && permission_set.permissions.write && ( 106 + <button 107 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 108 + onClick={async () => { 109 + let pollOptionEntity = v7(); 110 + await rep?.mutate.addPollOption({ 111 + pollEntity: props.entityID, 112 + pollOptionEntity, 113 + pollOptionName: "", 114 + permission_set: permission_set.set, 115 + factID: v7(), 116 + }); 117 + 118 + focusElement( 119 + document.getElementById( 120 + elementId.block(props.entityID).pollInput(pollOptionEntity), 121 + ) as HTMLInputElement | null, 122 + ); 123 + }} 124 + > 125 + Add an Option 126 + </button> 127 + )} 128 + </> 129 + ); 130 + }; 131 + 132 + const EditPollOptionForPublication = (props: { 133 + entityID: string; 134 + pollEntity: string; 135 + disabled: boolean; 136 + canDelete: boolean; 137 + }) => { 138 + let { rep } = useReplicache(); 139 + let { permissions } = useEntitySetContext(); 140 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 141 + 142 + return ( 143 + <div className="flex gap-2 items-center"> 144 + <AsyncValueInput 145 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 146 + type="text" 147 + className="pollOptionInput w-full input-with-border" 148 + placeholder="Option here..." 149 + disabled={props.disabled || !permissions.write} 150 + value={optionName || ""} 151 + onChange={async (e) => { 152 + await rep?.mutate.assertFact([ 153 + { 154 + entity: props.entityID, 155 + attribute: "poll-option/name", 156 + data: { type: "string", value: e.currentTarget.value }, 157 + }, 158 + ]); 159 + }} 160 + onKeyDown={(e) => { 161 + if ( 162 + props.canDelete && 163 + e.key === "Backspace" && 164 + !e.currentTarget.value 165 + ) { 166 + e.preventDefault(); 167 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 168 + } 169 + }} 170 + /> 171 + 172 + {permissions.write && props.canDelete && ( 173 + <button 174 + tabIndex={-1} 175 + className="text-accent-contrast" 176 + onMouseDown={async () => { 177 + await rep?.mutate.removePollOption({ 178 + optionEntity: props.entityID, 179 + }); 180 + }} 181 + > 182 + <CloseTiny /> 183 + </button> 184 + )} 185 + </div> 186 + ); 187 + };
+12 -1
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 3 3 4 4 export const publicationsRelations = relations(publications, ({one, many}) => ({ 5 5 identity: one(identities, { ··· 180 180 fields: [email_subscriptions_to_entity.token], 181 181 references: [permission_tokens.id] 182 182 }), 183 + })); 184 + 185 + export const atp_poll_votesRelations = relations(atp_poll_votes, ({one}) => ({ 186 + atp_poll_record: one(atp_poll_records, { 187 + fields: [atp_poll_votes.poll_uri], 188 + references: [atp_poll_records.uri] 189 + }), 190 + })); 191 + 192 + export const atp_poll_recordsRelations = relations(atp_poll_records, ({many}) => ({ 193 + atp_poll_votes: many(atp_poll_votes), 183 194 })); 184 195 185 196 export const bsky_followsRelations = relations(bsky_follows, ({one}) => ({
+24 -1
drizzle/schema.ts
··· 204 204 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 205 205 }); 206 206 207 + export const atp_poll_votes = pgTable("atp_poll_votes", { 208 + uri: text("uri").primaryKey().notNull(), 209 + record: jsonb("record").notNull(), 210 + voter_did: text("voter_did").notNull(), 211 + poll_uri: text("poll_uri").notNull().references(() => atp_poll_records.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 212 + poll_cid: text("poll_cid").notNull(), 213 + option: text("option").notNull(), 214 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 215 + }, 216 + (table) => { 217 + return { 218 + poll_uri_idx: index("atp_poll_votes_poll_uri_idx").on(table.poll_uri), 219 + voter_did_idx: index("atp_poll_votes_voter_did_idx").on(table.voter_did), 220 + } 221 + }); 222 + 223 + export const atp_poll_records = pgTable("atp_poll_records", { 224 + uri: text("uri").primaryKey().notNull(), 225 + cid: text("cid").notNull(), 226 + record: jsonb("record").notNull(), 227 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 228 + }); 229 + 207 230 export const oauth_session_store = pgTable("oauth_session_store", { 208 231 key: text("key").primaryKey().notNull(), 209 232 session: jsonb("session").notNull(), 210 233 }); 211 234 212 235 export const bsky_follows = pgTable("bsky_follows", { 213 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 236 + identity: text("identity").default('').notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 214 237 follows: text("follows").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 215 238 }, 216 239 (table) => {
+182
lexicons/api/index.ts
··· 32 32 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 33 33 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 34 34 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 35 + import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 35 36 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 36 37 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 37 38 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 40 41 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 41 42 import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 42 43 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 44 + import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 45 + import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 43 46 import * as PubLeafletPublication from './types/pub/leaflet/publication' 44 47 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 45 48 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 68 71 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 69 72 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 70 73 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 74 + export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 71 75 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 72 76 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 73 77 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 76 80 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 77 81 export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 78 82 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 83 + export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 84 + export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 79 85 export * as PubLeafletPublication from './types/pub/leaflet/publication' 80 86 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 81 87 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 385 391 blocks: PubLeafletBlocksNS 386 392 graph: PubLeafletGraphNS 387 393 pages: PubLeafletPagesNS 394 + poll: PubLeafletPollNS 388 395 richtext: PubLeafletRichtextNS 389 396 theme: PubLeafletThemeNS 390 397 ··· 393 400 this.blocks = new PubLeafletBlocksNS(client) 394 401 this.graph = new PubLeafletGraphNS(client) 395 402 this.pages = new PubLeafletPagesNS(client) 403 + this.poll = new PubLeafletPollNS(client) 396 404 this.richtext = new PubLeafletRichtextNS(client) 397 405 this.theme = new PubLeafletThemeNS(client) 398 406 this.comment = new PubLeafletCommentRecord(client) ··· 507 515 508 516 constructor(client: XrpcClient) { 509 517 this._client = client 518 + } 519 + } 520 + 521 + export class PubLeafletPollNS { 522 + _client: XrpcClient 523 + definition: PubLeafletPollDefinitionRecord 524 + vote: PubLeafletPollVoteRecord 525 + 526 + constructor(client: XrpcClient) { 527 + this._client = client 528 + this.definition = new PubLeafletPollDefinitionRecord(client) 529 + this.vote = new PubLeafletPollVoteRecord(client) 530 + } 531 + } 532 + 533 + export class PubLeafletPollDefinitionRecord { 534 + _client: XrpcClient 535 + 536 + constructor(client: XrpcClient) { 537 + this._client = client 538 + } 539 + 540 + async list( 541 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 542 + ): Promise<{ 543 + cursor?: string 544 + records: { uri: string; value: PubLeafletPollDefinition.Record }[] 545 + }> { 546 + const res = await this._client.call('com.atproto.repo.listRecords', { 547 + collection: 'pub.leaflet.poll.definition', 548 + ...params, 549 + }) 550 + return res.data 551 + } 552 + 553 + async get( 554 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 555 + ): Promise<{ 556 + uri: string 557 + cid: string 558 + value: PubLeafletPollDefinition.Record 559 + }> { 560 + const res = await this._client.call('com.atproto.repo.getRecord', { 561 + collection: 'pub.leaflet.poll.definition', 562 + ...params, 563 + }) 564 + return res.data 565 + } 566 + 567 + async create( 568 + params: OmitKey< 569 + ComAtprotoRepoCreateRecord.InputSchema, 570 + 'collection' | 'record' 571 + >, 572 + record: Un$Typed<PubLeafletPollDefinition.Record>, 573 + headers?: Record<string, string>, 574 + ): Promise<{ uri: string; cid: string }> { 575 + const collection = 'pub.leaflet.poll.definition' 576 + const res = await this._client.call( 577 + 'com.atproto.repo.createRecord', 578 + undefined, 579 + { collection, ...params, record: { ...record, $type: collection } }, 580 + { encoding: 'application/json', headers }, 581 + ) 582 + return res.data 583 + } 584 + 585 + async put( 586 + params: OmitKey< 587 + ComAtprotoRepoPutRecord.InputSchema, 588 + 'collection' | 'record' 589 + >, 590 + record: Un$Typed<PubLeafletPollDefinition.Record>, 591 + headers?: Record<string, string>, 592 + ): Promise<{ uri: string; cid: string }> { 593 + const collection = 'pub.leaflet.poll.definition' 594 + const res = await this._client.call( 595 + 'com.atproto.repo.putRecord', 596 + undefined, 597 + { collection, ...params, record: { ...record, $type: collection } }, 598 + { encoding: 'application/json', headers }, 599 + ) 600 + return res.data 601 + } 602 + 603 + async delete( 604 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 605 + headers?: Record<string, string>, 606 + ): Promise<void> { 607 + await this._client.call( 608 + 'com.atproto.repo.deleteRecord', 609 + undefined, 610 + { collection: 'pub.leaflet.poll.definition', ...params }, 611 + { headers }, 612 + ) 613 + } 614 + } 615 + 616 + export class PubLeafletPollVoteRecord { 617 + _client: XrpcClient 618 + 619 + constructor(client: XrpcClient) { 620 + this._client = client 621 + } 622 + 623 + async list( 624 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 625 + ): Promise<{ 626 + cursor?: string 627 + records: { uri: string; value: PubLeafletPollVote.Record }[] 628 + }> { 629 + const res = await this._client.call('com.atproto.repo.listRecords', { 630 + collection: 'pub.leaflet.poll.vote', 631 + ...params, 632 + }) 633 + return res.data 634 + } 635 + 636 + async get( 637 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 638 + ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> { 639 + const res = await this._client.call('com.atproto.repo.getRecord', { 640 + collection: 'pub.leaflet.poll.vote', 641 + ...params, 642 + }) 643 + return res.data 644 + } 645 + 646 + async create( 647 + params: OmitKey< 648 + ComAtprotoRepoCreateRecord.InputSchema, 649 + 'collection' | 'record' 650 + >, 651 + record: Un$Typed<PubLeafletPollVote.Record>, 652 + headers?: Record<string, string>, 653 + ): Promise<{ uri: string; cid: string }> { 654 + const collection = 'pub.leaflet.poll.vote' 655 + const res = await this._client.call( 656 + 'com.atproto.repo.createRecord', 657 + undefined, 658 + { collection, ...params, record: { ...record, $type: collection } }, 659 + { encoding: 'application/json', headers }, 660 + ) 661 + return res.data 662 + } 663 + 664 + async put( 665 + params: OmitKey< 666 + ComAtprotoRepoPutRecord.InputSchema, 667 + 'collection' | 'record' 668 + >, 669 + record: Un$Typed<PubLeafletPollVote.Record>, 670 + headers?: Record<string, string>, 671 + ): Promise<{ uri: string; cid: string }> { 672 + const collection = 'pub.leaflet.poll.vote' 673 + const res = await this._client.call( 674 + 'com.atproto.repo.putRecord', 675 + undefined, 676 + { collection, ...params, record: { ...record, $type: collection } }, 677 + { encoding: 'application/json', headers }, 678 + ) 679 + return res.data 680 + } 681 + 682 + async delete( 683 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 684 + headers?: Record<string, string>, 685 + ): Promise<void> { 686 + await this._client.call( 687 + 'com.atproto.repo.deleteRecord', 688 + undefined, 689 + { collection: 'pub.leaflet.poll.vote', ...params }, 690 + { headers }, 691 + ) 510 692 } 511 693 } 512 694
+90
lexicons/api/lexicons.ts
··· 1200 1200 }, 1201 1201 }, 1202 1202 }, 1203 + PubLeafletBlocksPoll: { 1204 + lexicon: 1, 1205 + id: 'pub.leaflet.blocks.poll', 1206 + defs: { 1207 + main: { 1208 + type: 'object', 1209 + required: ['pollRef'], 1210 + properties: { 1211 + pollRef: { 1212 + type: 'ref', 1213 + ref: 'lex:com.atproto.repo.strongRef', 1214 + }, 1215 + }, 1216 + }, 1217 + }, 1218 + }, 1203 1219 PubLeafletBlocksText: { 1204 1220 lexicon: 1, 1205 1221 id: 'pub.leaflet.blocks.text', ··· 1574 1590 'lex:pub.leaflet.blocks.horizontalRule', 1575 1591 'lex:pub.leaflet.blocks.bskyPost', 1576 1592 'lex:pub.leaflet.blocks.page', 1593 + 'lex:pub.leaflet.blocks.poll', 1577 1594 ], 1578 1595 }, 1579 1596 alignment: { ··· 1627 1644 }, 1628 1645 }, 1629 1646 }, 1647 + PubLeafletPollDefinition: { 1648 + lexicon: 1, 1649 + id: 'pub.leaflet.poll.definition', 1650 + defs: { 1651 + main: { 1652 + type: 'record', 1653 + key: 'tid', 1654 + description: 'Record declaring a poll', 1655 + record: { 1656 + type: 'object', 1657 + required: ['name', 'options'], 1658 + properties: { 1659 + name: { 1660 + type: 'string', 1661 + maxLength: 500, 1662 + maxGraphemes: 100, 1663 + }, 1664 + options: { 1665 + type: 'array', 1666 + items: { 1667 + type: 'ref', 1668 + ref: 'lex:pub.leaflet.poll.definition#option', 1669 + }, 1670 + }, 1671 + endDate: { 1672 + type: 'string', 1673 + format: 'datetime', 1674 + }, 1675 + }, 1676 + }, 1677 + }, 1678 + option: { 1679 + type: 'object', 1680 + properties: { 1681 + text: { 1682 + type: 'string', 1683 + maxLength: 500, 1684 + maxGraphemes: 50, 1685 + }, 1686 + }, 1687 + }, 1688 + }, 1689 + }, 1690 + PubLeafletPollVote: { 1691 + lexicon: 1, 1692 + id: 'pub.leaflet.poll.vote', 1693 + defs: { 1694 + main: { 1695 + type: 'record', 1696 + key: 'tid', 1697 + description: 'Record declaring a vote on a poll', 1698 + record: { 1699 + type: 'object', 1700 + required: ['poll', 'option'], 1701 + properties: { 1702 + poll: { 1703 + type: 'ref', 1704 + ref: 'lex:com.atproto.repo.strongRef', 1705 + }, 1706 + option: { 1707 + type: 'array', 1708 + items: { 1709 + type: 'string', 1710 + }, 1711 + }, 1712 + }, 1713 + }, 1714 + }, 1715 + }, 1716 + }, 1630 1717 PubLeafletPublication: { 1631 1718 lexicon: 1, 1632 1719 id: 'pub.leaflet.publication', ··· 1968 2055 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1969 2056 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1970 2057 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2058 + PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 1971 2059 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1972 2060 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1973 2061 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', ··· 1976 2064 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 1977 2065 PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 1978 2066 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2067 + PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 2068 + PubLeafletPollVote: 'pub.leaflet.poll.vote', 1979 2069 PubLeafletPublication: 'pub.leaflet.publication', 1980 2070 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1981 2071 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage',
+31
lexicons/api/types/pub/leaflet/blocks/poll.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.blocks.poll' 17 + 18 + export interface Main { 19 + $type?: 'pub.leaflet.blocks.poll' 20 + pollRef: ComAtprotoRepoStrongRef.Main 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+2
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 21 21 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 22 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 23 import type * as PubLeafletBlocksPage from '../blocks/page' 24 + import type * as PubLeafletBlocksPoll from '../blocks/poll' 24 25 25 26 const is$typed = _is$typed, 26 27 validate = _validate ··· 57 58 | $Typed<PubLeafletBlocksHorizontalRule.Main> 58 59 | $Typed<PubLeafletBlocksBskyPost.Main> 59 60 | $Typed<PubLeafletBlocksPage.Main> 61 + | $Typed<PubLeafletBlocksPoll.Main> 60 62 | { $type: string } 61 63 alignment?: 62 64 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+48
lexicons/api/types/pub/leaflet/poll/definition.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.poll.definition' 16 + 17 + export interface Record { 18 + $type: 'pub.leaflet.poll.definition' 19 + name: string 20 + options: Option[] 21 + endDate?: string 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + } 34 + 35 + export interface Option { 36 + $type?: 'pub.leaflet.poll.definition#option' 37 + text?: string 38 + } 39 + 40 + const hashOption = 'option' 41 + 42 + export function isOption<V>(v: V) { 43 + return is$typed(v, id, hashOption) 44 + } 45 + 46 + export function validateOption<V>(v: V) { 47 + return validate<Option & V>(v, id, hashOption) 48 + }
+33
lexicons/api/types/pub/leaflet/poll/vote.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.poll.vote' 17 + 18 + export interface Record { 19 + $type: 'pub.leaflet.poll.vote' 20 + poll: ComAtprotoRepoStrongRef.Main 21 + option: string[] 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + }
+2
lexicons/build.ts
··· 2 2 import { BlockLexicons } from "./src/blocks"; 3 3 import { PubLeafletDocument } from "./src/document"; 4 4 import * as PublicationLexicons from "./src/publication"; 5 + import * as PollLexicons from "./src/polls"; 5 6 import { ThemeLexicons } from "./src/theme"; 6 7 7 8 import * as fs from "fs"; ··· 25 26 ...ThemeLexicons, 26 27 ...BlockLexicons, 27 28 ...Object.values(PublicationLexicons), 29 + ...Object.values(PollLexicons), 28 30 ]; 29 31 30 32 // Write each lexicon to a file
+18
lexicons/pub/leaflet/blocks/poll.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.poll", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "pollRef" 9 + ], 10 + "properties": { 11 + "pollRef": { 12 + "type": "ref", 13 + "ref": "com.atproto.repo.strongRef" 14 + } 15 + } 16 + } 17 + } 18 + }
+2 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 40 40 "pub.leaflet.blocks.code", 41 41 "pub.leaflet.blocks.horizontalRule", 42 42 "pub.leaflet.blocks.bskyPost", 43 - "pub.leaflet.blocks.page" 43 + "pub.leaflet.blocks.page", 44 + "pub.leaflet.blocks.poll" 44 45 ] 45 46 }, 46 47 "alignment": {
+46
lexicons/pub/leaflet/poll/definition.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.definition", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "name", 13 + "options" 14 + ], 15 + "properties": { 16 + "name": { 17 + "type": "string", 18 + "maxLength": 500, 19 + "maxGraphemes": 100 20 + }, 21 + "options": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#option" 26 + } 27 + }, 28 + "endDate": { 29 + "type": "string", 30 + "format": "datetime" 31 + } 32 + } 33 + } 34 + }, 35 + "option": { 36 + "type": "object", 37 + "properties": { 38 + "text": { 39 + "type": "string", 40 + "maxLength": 500, 41 + "maxGraphemes": 50 42 + } 43 + } 44 + } 45 + } 46 + }
+30
lexicons/pub/leaflet/poll/vote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.vote", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a vote on a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "poll", 13 + "option" 14 + ], 15 + "properties": { 16 + "poll": { 17 + "type": "ref", 18 + "ref": "com.atproto.repo.strongRef" 19 + }, 20 + "option": { 21 + "type": "array", 22 + "items": { 23 + "type": "string" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+16
lexicons/src/blocks.ts
··· 264 264 }, 265 265 }, 266 266 }; 267 + 268 + export const PubLeafletBlocksPoll: LexiconDoc = { 269 + lexicon: 1, 270 + id: "pub.leaflet.blocks.poll", 271 + defs: { 272 + main: { 273 + type: "object", 274 + required: ["pollRef"], 275 + properties: { 276 + pollRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 277 + }, 278 + }, 279 + }, 280 + }; 281 + 267 282 export const BlockLexicons = [ 268 283 PubLeafletBlocksIFrame, 269 284 PubLeafletBlocksText, ··· 277 292 PubLeafletBlocksHorizontalRule, 278 293 PubLeafletBlocksBskyPost, 279 294 PubLeafletBlocksPage, 295 + PubLeafletBlocksPoll, 280 296 ]; 281 297 export const BlockUnion: LexRefUnion = { 282 298 type: "union",
+48
lexicons/src/polls/index.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + 3 + export const PubLeafletPollDefinition: LexiconDoc = { 4 + lexicon: 1, 5 + id: "pub.leaflet.poll.definition", 6 + defs: { 7 + main: { 8 + type: "record", 9 + key: "tid", 10 + description: "Record declaring a poll", 11 + record: { 12 + type: "object", 13 + required: ["name", "options"], 14 + properties: { 15 + name: { type: "string", maxLength: 500, maxGraphemes: 100 }, 16 + options: { type: "array", items: { type: "ref", ref: "#option" } }, 17 + endDate: { type: "string", format: "datetime" }, 18 + }, 19 + }, 20 + }, 21 + option: { 22 + type: "object", 23 + properties: { 24 + text: { type: "string", maxLength: 500, maxGraphemes: 50 }, 25 + }, 26 + }, 27 + }, 28 + }; 29 + 30 + export const PubLeafletPollVote: LexiconDoc = { 31 + lexicon: 1, 32 + id: "pub.leaflet.poll.vote", 33 + defs: { 34 + main: { 35 + type: "record", 36 + key: "tid", 37 + description: "Record declaring a vote on a poll", 38 + record: { 39 + type: "object", 40 + required: ["poll", "option"], 41 + properties: { 42 + poll: { type: "ref", ref: "com.atproto.repo.strongRef" }, 43 + option: { type: "array", items: { type: "string" } }, 44 + }, 45 + }, 46 + }, 47 + }, 48 + };
+56
supabase/database.types.ts
··· 34 34 } 35 35 public: { 36 36 Tables: { 37 + atp_poll_records: { 38 + Row: { 39 + cid: string 40 + created_at: string 41 + record: Json 42 + uri: string 43 + } 44 + Insert: { 45 + cid: string 46 + created_at?: string 47 + record: Json 48 + uri: string 49 + } 50 + Update: { 51 + cid?: string 52 + created_at?: string 53 + record?: Json 54 + uri?: string 55 + } 56 + Relationships: [] 57 + } 58 + atp_poll_votes: { 59 + Row: { 60 + indexed_at: string 61 + poll_cid: string 62 + poll_uri: string 63 + record: Json 64 + uri: string 65 + voter_did: string 66 + } 67 + Insert: { 68 + indexed_at?: string 69 + poll_cid: string 70 + poll_uri: string 71 + record: Json 72 + uri: string 73 + voter_did: string 74 + } 75 + Update: { 76 + indexed_at?: string 77 + poll_cid?: string 78 + poll_uri?: string 79 + record?: Json 80 + uri?: string 81 + voter_did?: string 82 + } 83 + Relationships: [ 84 + { 85 + foreignKeyName: "atp_poll_votes_poll_uri_fkey" 86 + columns: ["poll_uri"] 87 + isOneToOne: false 88 + referencedRelation: "atp_poll_records" 89 + referencedColumns: ["uri"] 90 + }, 91 + ] 92 + } 37 93 bsky_follows: { 38 94 Row: { 39 95 follows: string