a tool for shared writing and social publishing

add publication poll components

+1213 -12
+62 -8
actions/publishToPublication.ts
··· 21 21 PubLeafletBlocksBlockquote, 22 22 PubLeafletBlocksIframe, 23 23 PubLeafletBlocksPage, 24 + PubLeafletBlocksPoll, 25 + PubLeafletPollDefinition, 24 26 } from "lexicons/api"; 25 27 import { Block } from "components/Blocks/Block"; 26 28 import { TID } from "@atproto/common"; ··· 78 80 facts, 79 81 agent, 80 82 root_entity, 83 + credentialSession.did!, 81 84 ); 82 85 83 86 let existingRecord = ··· 137 140 facts: Fact<any>[], 138 141 agent: AtpBaseClient, 139 142 root_entity: string, 143 + did: string, 140 144 ) { 141 145 let scan = scanIndexLocal(facts); 142 146 let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = ··· 145 149 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 146 150 if (!firstEntity) throw new Error("No root page"); 147 151 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 148 - let b = await blocksToRecord(blocks); 152 + let b = await blocksToRecord(blocks, did); 149 153 return { firstPageBlocks: b, pages }; 150 154 151 155 async function uploadImage(src: string) { ··· 159 163 } 160 164 async function blocksToRecord( 161 165 blocks: Block[], 166 + did: string, 162 167 ): Promise<PubLeafletPagesLinearDocument.Block[]> { 163 168 let parsedBlocks = parseBlocksToList(blocks); 164 169 return ( ··· 174 179 : alignmentValue === "right" 175 180 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 176 181 : undefined; 177 - let b = await blockToRecord(blockOrList.block); 182 + let b = await blockToRecord(blockOrList.block, did); 178 183 if (!b) return []; 179 184 let block: PubLeafletPagesLinearDocument.Block = { 180 185 $type: "pub.leaflet.pages.linearDocument#block", ··· 187 192 $type: "pub.leaflet.pages.linearDocument#block", 188 193 block: { 189 194 $type: "pub.leaflet.blocks.unorderedList", 190 - children: await childrenToRecord(blockOrList.children), 195 + children: await childrenToRecord(blockOrList.children, did), 191 196 }, 192 197 }; 193 198 return [block]; ··· 197 202 ).flat(); 198 203 } 199 204 200 - async function childrenToRecord(children: List[]) { 205 + async function childrenToRecord(children: List[], did: string) { 201 206 return ( 202 207 await Promise.all( 203 208 children.map(async (child) => { 204 - let content = await blockToRecord(child.block); 209 + let content = await blockToRecord(child.block, did); 205 210 if (!content) return []; 206 211 let record: PubLeafletBlocksUnorderedList.ListItem = { 207 212 $type: "pub.leaflet.blocks.unorderedList#listItem", 208 213 content, 209 - children: await childrenToRecord(child.children), 214 + children: await childrenToRecord(child.children, did), 210 215 }; 211 216 return record; 212 217 }), 213 218 ) 214 219 ).flat(); 215 220 } 216 - async function blockToRecord(b: Block) { 221 + async function blockToRecord(b: Block, did: string) { 217 222 const getBlockContent = (b: string) => { 218 223 let [content] = scan.eav(b, "block/text"); 219 224 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; ··· 231 236 let blocks = getBlocksWithTypeLocal(facts, page.data.value); 232 237 pages.push({ 233 238 id: page.data.value, 234 - blocks: await blocksToRecord(blocks), 239 + blocks: await blocksToRecord(blocks, did), 235 240 }); 236 241 let block: $Typed<PubLeafletBlocksPage.Main> = { 237 242 $type: "pub.leaflet.blocks.page", ··· 354 359 let block: $Typed<PubLeafletBlocksMath.Main> = { 355 360 $type: "pub.leaflet.blocks.math", 356 361 tex: math?.data.value || "", 362 + }; 363 + return block; 364 + } 365 + if (b.type === "poll") { 366 + // Get poll options from the entity 367 + let pollOptions = scan.eav(b.value, "poll/options"); 368 + let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 369 + (opt) => { 370 + let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 371 + return { 372 + $type: "pub.leaflet.poll.definition#option", 373 + text: optionName?.data.value || "", 374 + }; 375 + }, 376 + ); 377 + 378 + // Create the poll definition record 379 + let pollRecord: PubLeafletPollDefinition.Record = { 380 + $type: "pub.leaflet.poll.definition", 381 + name: "Poll", // Default name, can be customized 382 + options, 383 + }; 384 + 385 + // Upload the poll record 386 + let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 387 + //use the entity id as the rkey so we can associate it in the editor 388 + rkey: b.value, 389 + repo: did, 390 + collection: pollRecord.$type, 391 + record: pollRecord, 392 + validate: false, 393 + }); 394 + 395 + // Optimistically write poll definition to database 396 + console.log( 397 + await supabaseServerClient.from("atp_poll_records").upsert({ 398 + uri: pollResult.uri, 399 + cid: pollResult.cid, 400 + record: pollRecord as Json, 401 + }), 402 + ); 403 + 404 + // Return a poll block with reference to the poll record 405 + let block: $Typed<PubLeafletBlocksPoll.Main> = { 406 + $type: "pub.leaflet.blocks.poll", 407 + pollRef: { 408 + uri: pollResult.uri, 409 + cid: pollResult.cid, 410 + }, 357 411 }; 358 412 return block; 359 413 }
+24
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 14 14 PubLeafletBlocksBskyPost, 15 15 PubLeafletBlocksIframe, 16 16 PubLeafletBlocksPage, 17 + PubLeafletBlocksPoll, 17 18 } from "lexicons/api"; 18 19 19 20 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 28 29 import { openPage } from "./PostPages"; 29 30 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 30 31 import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 32 + import { PublishedPollBlock } from "./PublishedPollBlock"; 33 + import { PollData } from "./fetchPollData"; 31 34 32 35 export function PostContent({ 33 36 blocks, ··· 38 41 bskyPostData, 39 42 pageId, 40 43 pages, 44 + pollData, 41 45 }: { 42 46 blocks: PubLeafletPagesLinearDocument.Block[]; 43 47 pageId?: string; ··· 47 51 prerenderedCodeBlocks?: Map<string, string>; 48 52 bskyPostData: AppBskyFeedDefs.PostView[]; 49 53 pages: PubLeafletPagesLinearDocument.Main[]; 54 + pollData: PollData[]; 50 55 }) { 51 56 return ( 52 57 <div ··· 66 71 index={[index]} 67 72 preview={preview} 68 73 prerenderedCodeBlocks={prerenderedCodeBlocks} 74 + pollData={pollData} 69 75 /> 70 76 ); 71 77 })} ··· 84 90 bskyPostData, 85 91 pageId, 86 92 pages, 93 + pollData, 87 94 }: { 88 95 pageId?: string; 89 96 preview?: boolean; ··· 95 102 previousBlock?: PubLeafletPagesLinearDocument.Block; 96 103 prerenderedCodeBlocks?: Map<string, string>; 97 104 bskyPostData: AppBskyFeedDefs.PostView[]; 105 + pollData: PollData[]; 98 106 }) => { 99 107 let b = block; 100 108 let blockProps = { ··· 168 176 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 169 177 return <hr className="my-2 w-full border-border-light" />; 170 178 } 179 + case PubLeafletBlocksPoll.isMain(b.block): { 180 + let { cid, uri } = b.block.pollRef; 181 + const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); 182 + if (!pollVoteData) return null; 183 + return ( 184 + <PublishedPollBlock 185 + block={b.block} 186 + className={className} 187 + pollData={pollVoteData} 188 + /> 189 + ); 190 + } 171 191 case PubLeafletBlocksUnorderedList.isMain(b.block): { 172 192 return ( 173 193 <ul className="-ml-px sm:ml-[9px] pb-2"> 174 194 {b.block.children.map((child, i) => ( 175 195 <ListItem 196 + pollData={pollData} 176 197 pages={pages} 177 198 bskyPostData={bskyPostData} 178 199 index={[...index, i]} ··· 359 380 did: string; 360 381 className?: string; 361 382 bskyPostData: AppBskyFeedDefs.PostView[]; 383 + pollData: PollData[]; 362 384 pageId?: string; 363 385 }) { 364 386 let children = props.item.children?.length ? ( ··· 366 388 {props.item.children.map((child, index) => ( 367 389 <ListItem 368 390 pages={props.pages} 391 + pollData={props.pollData} 369 392 bskyPostData={props.bskyPostData} 370 393 index={[...props.index, index]} 371 394 item={child} ··· 384 407 /> 385 408 <div className="flex flex-col w-full"> 386 409 <Block 410 + pollData={props.pollData} 387 411 pages={props.pages} 388 412 bskyPostData={props.bskyPostData} 389 413 block={{ block: props.item.content }}
+5
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 29 29 import { scrollIntoView } from "src/utils/scrollIntoView"; 30 30 import { useParams } from "next/navigation"; 31 31 import { decodeQuotePosition } from "./quotePosition"; 32 + import { PollData } from "./fetchPollData"; 32 33 33 34 const usePostPageUIState = create(() => ({ 34 35 pages: [] as string[], ··· 113 114 prerenderedCodeBlocks, 114 115 bskyPostData, 115 116 document_uri, 117 + pollData, 116 118 }: { 117 119 document_uri: string; 118 120 document: PostPageData; ··· 123 125 prerenderedCodeBlocks?: Map<string, string>; 124 126 bskyPostData: AppBskyFeedDefs.PostView[]; 125 127 preferences: { showComments?: boolean }; 128 + pollData: PollData[]; 126 129 }) { 127 130 let { identity } = useIdentityData(); 128 131 let drawer = useDrawerOpen(document_uri); ··· 155 158 blocks={blocks} 156 159 did={did} 157 160 prerenderedCodeBlocks={prerenderedCodeBlocks} 161 + pollData={pollData} 158 162 /> 159 163 <Interactions 160 164 showComments={preferences.showComments} ··· 244 248 blocks={page.blocks} 245 249 did={did} 246 250 prerenderedCodeBlocks={prerenderedCodeBlocks} 251 + pollData={pollData} 247 252 /> 248 253 <Interactions 249 254 pageId={page.id}
+220
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 + "use client"; 2 + 3 + import { PubLeafletBlocksPoll, PubLeafletPollDefinition } 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 + export const PublishedPollBlock = (props: { 15 + block: PubLeafletBlocksPoll.Main; 16 + pollData: PollData; 17 + className?: string; 18 + }) => { 19 + const { identity } = useIdentityData(); 20 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 21 + const [isVoting, setIsVoting] = useState(false); 22 + const [showResults, setShowResults] = useState(false); 23 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 24 + let [isClient, setIsClient] = useState(false); 25 + useEffect(() => { 26 + setIsClient(true); 27 + }, []); 28 + 29 + const handleVote = async () => { 30 + if (!selectedOption) return; 31 + 32 + setIsVoting(true); 33 + try { 34 + const result = await voteOnPublishedPoll( 35 + props.block.pollRef.uri, 36 + props.block.pollRef.cid, 37 + selectedOption, 38 + ); 39 + 40 + if (result.success) { 41 + setShowResults(true); 42 + } else { 43 + console.error("Failed to vote:", result.error); 44 + } 45 + } catch (error) { 46 + console.error("Failed to vote:", error); 47 + } finally { 48 + setIsVoting(false); 49 + } 50 + }; 51 + 52 + const hasVoted = 53 + !!identity?.atp_did && 54 + !!props.pollData?.atp_poll_votes.find( 55 + (v) => v.voter_did === identity?.atp_did, 56 + ); 57 + const displayResults = showResults || hasVoted; 58 + 59 + return ( 60 + <div 61 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 62 + style={{ 63 + backgroundColor: 64 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 65 + }} 66 + > 67 + {displayResults ? ( 68 + <PollResults 69 + pollData={props.pollData} 70 + hasVoted={hasVoted} 71 + setShowResults={setShowResults} 72 + /> 73 + ) : ( 74 + <> 75 + {pollRecord.options.map((option, index) => ( 76 + <PollOptionButton 77 + key={index} 78 + option={option} 79 + optionIndex={index.toString()} 80 + selected={selectedOption === index.toString()} 81 + onSelect={() => setSelectedOption(index.toString())} 82 + /> 83 + ))} 84 + <div className="flex justify-between items-center"> 85 + <div className="flex justify-end gap-2"> 86 + {identity?.atp_did && ( 87 + <button 88 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 89 + onClick={() => setShowResults(!showResults)} 90 + > 91 + See Results 92 + </button> 93 + )} 94 + </div> 95 + {identity?.atp_did ? ( 96 + <ButtonPrimary 97 + className="place-self-end" 98 + onClick={handleVote} 99 + disabled={!selectedOption || isVoting} 100 + > 101 + {isVoting ? "Voting..." : "Vote!"} 102 + </ButtonPrimary> 103 + ) : ( 104 + <Popover 105 + asChild 106 + trigger={ 107 + <ButtonPrimary className="place-self-center"> 108 + <BlueskyTiny /> Login to vote 109 + </ButtonPrimary> 110 + } 111 + > 112 + {isClient && ( 113 + <LoginForm 114 + text="Log in to vote on this poll!" 115 + noEmail 116 + redirectRoute={window?.location.href + "?refreshAuth"} 117 + /> 118 + )} 119 + </Popover> 120 + )} 121 + </div> 122 + </> 123 + )} 124 + </div> 125 + ); 126 + }; 127 + 128 + const PollOptionButton = (props: { 129 + option: PubLeafletPollDefinition.Option; 130 + optionIndex: string; 131 + selected: boolean; 132 + onSelect: () => void; 133 + }) => { 134 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 135 + 136 + return ( 137 + <div className="flex gap-2 items-center"> 138 + <ButtonComponent 139 + className="pollOption grow max-w-full flex" 140 + onClick={props.onSelect} 141 + > 142 + {props.option.text} 143 + </ButtonComponent> 144 + </div> 145 + ); 146 + }; 147 + 148 + const PollResults = (props: { 149 + pollData: PollData; 150 + hasVoted: boolean; 151 + setShowResults: (show: boolean) => void; 152 + }) => { 153 + const totalVotes = props.pollData.atp_poll_votes.length || 0; 154 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 155 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 156 + ...o, 157 + votes: props.pollData.atp_poll_votes.filter( 158 + (v) => v.option == index.toString(), 159 + ), 160 + })); 161 + 162 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 163 + return ( 164 + <> 165 + {pollRecord.options.map((option, index) => { 166 + const votes = props.pollData?.atp_poll_votes.filter( 167 + (v) => v.option === index.toString(), 168 + ).length; 169 + const isWinner = totalVotes > 0 && votes === highestVotes; 170 + 171 + return ( 172 + <PollResult 173 + key={index} 174 + option={option} 175 + votes={votes} 176 + totalVotes={totalVotes} 177 + winner={isWinner} 178 + /> 179 + ); 180 + })} 181 + </> 182 + ); 183 + }; 184 + 185 + const PollResult = (props: { 186 + option: PubLeafletPollDefinition.Option; 187 + votes: number; 188 + totalVotes: number; 189 + winner: boolean; 190 + }) => { 191 + return ( 192 + <div 193 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 194 + > 195 + <div 196 + style={{ 197 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 198 + paintOrder: "stroke fill", 199 + }} 200 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 201 + > 202 + <div className="grow max-w-full truncate">{props.option.text}</div> 203 + <div>{props.votes}</div> 204 + </div> 205 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 206 + <div 207 + className="bg-accent-contrast rounded-[2px] m-0.5" 208 + style={{ 209 + maskImage: "var(--hatchSVG)", 210 + maskRepeat: "repeat repeat", 211 + ...(props.votes === 0 212 + ? { width: "4px" } 213 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 214 + }} 215 + /> 216 + <div /> 217 + </div> 218 + </div> 219 + ); 220 + };
+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: { option: string; 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
+65
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 + option: selectedOption, 46 + record: voteRecord as unknown as Json, 47 + }); 48 + 49 + // Create the record on ATP 50 + await agent.com.atproto.repo.createRecord({ 51 + repo: identity.atp_did, 52 + collection: "pub.leaflet.poll.vote", 53 + rkey, 54 + record: voteRecord, 55 + }); 56 + 57 + return { success: true }; 58 + } catch (error) { 59 + console.error("Failed to vote:", error); 60 + return { 61 + success: false, 62 + error: error instanceof Error ? error.message : "Failed to vote", 63 + }; 64 + } 65 + }
+42
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 + option: record.value.option, 186 + record: record.value as Json, 187 + }); 188 + } 189 + if (evt.event === "delete") { 190 + await supabase 191 + .from("atp_poll_votes") 192 + .delete() 193 + .eq("uri", evt.uri.toString()); 194 + } 195 + } 196 + if (evt.collection === ids.PubLeafletPollDefinition) { 197 + if (evt.event === "create" || evt.event === "update") { 198 + let record = PubLeafletPollDefinition.validateRecord(evt.record); 199 + if (!record.success) return; 200 + let { error } = await supabase.from("atp_poll_records").upsert({ 201 + uri: evt.uri.toString(), 202 + cid: evt.cid.toString(), 203 + record: record.value as Json, 204 + }); 205 + if (error) console.log("Error upserting poll definition:", error); 206 + } 207 + if (evt.event === "delete") { 208 + await supabase 209 + .from("atp_poll_records") 168 210 .delete() 169 211 .eq("uri", evt.uri.toString()); 170 212 }
-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 );
+186
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 && ( 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 optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 140 + 141 + return ( 142 + <div className="flex gap-2 items-center"> 143 + <AsyncValueInput 144 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 145 + type="text" 146 + className="pollOptionInput w-full input-with-border" 147 + placeholder="Option here..." 148 + disabled={props.disabled} 149 + value={optionName || ""} 150 + onChange={async (e) => { 151 + await rep?.mutate.assertFact([ 152 + { 153 + entity: props.entityID, 154 + attribute: "poll-option/name", 155 + data: { type: "string", value: e.currentTarget.value }, 156 + }, 157 + ]); 158 + }} 159 + onKeyDown={(e) => { 160 + if ( 161 + props.canDelete && 162 + e.key === "Backspace" && 163 + !e.currentTarget.value 164 + ) { 165 + e.preventDefault(); 166 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 167 + } 168 + }} 169 + /> 170 + 171 + {props.canDelete && ( 172 + <button 173 + tabIndex={-1} 174 + className="text-accent-contrast" 175 + onMouseDown={async () => { 176 + await rep?.mutate.removePollOption({ 177 + optionEntity: props.entityID, 178 + }); 179 + }} 180 + > 181 + <CloseTiny /> 182 + </button> 183 + )} 184 + </div> 185 + ); 186 + };
+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' ··· 39 40 import * as PubLeafletDocument from './types/pub/leaflet/document' 40 41 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 41 42 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 43 + import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 44 + import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 42 45 import * as PubLeafletPublication from './types/pub/leaflet/publication' 43 46 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 44 47 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 67 70 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 68 71 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 69 72 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 73 + export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 70 74 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 71 75 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 72 76 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 74 78 export * as PubLeafletDocument from './types/pub/leaflet/document' 75 79 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 76 80 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 81 + export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 82 + export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 77 83 export * as PubLeafletPublication from './types/pub/leaflet/publication' 78 84 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 79 85 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 380 386 blocks: PubLeafletBlocksNS 381 387 graph: PubLeafletGraphNS 382 388 pages: PubLeafletPagesNS 389 + poll: PubLeafletPollNS 383 390 richtext: PubLeafletRichtextNS 384 391 theme: PubLeafletThemeNS 385 392 ··· 388 395 this.blocks = new PubLeafletBlocksNS(client) 389 396 this.graph = new PubLeafletGraphNS(client) 390 397 this.pages = new PubLeafletPagesNS(client) 398 + this.poll = new PubLeafletPollNS(client) 391 399 this.richtext = new PubLeafletRichtextNS(client) 392 400 this.theme = new PubLeafletThemeNS(client) 393 401 this.comment = new PubLeafletCommentRecord(client) ··· 502 510 503 511 constructor(client: XrpcClient) { 504 512 this._client = client 513 + } 514 + } 515 + 516 + export class PubLeafletPollNS { 517 + _client: XrpcClient 518 + definition: PubLeafletPollDefinitionRecord 519 + vote: PubLeafletPollVoteRecord 520 + 521 + constructor(client: XrpcClient) { 522 + this._client = client 523 + this.definition = new PubLeafletPollDefinitionRecord(client) 524 + this.vote = new PubLeafletPollVoteRecord(client) 525 + } 526 + } 527 + 528 + export class PubLeafletPollDefinitionRecord { 529 + _client: XrpcClient 530 + 531 + constructor(client: XrpcClient) { 532 + this._client = client 533 + } 534 + 535 + async list( 536 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 537 + ): Promise<{ 538 + cursor?: string 539 + records: { uri: string; value: PubLeafletPollDefinition.Record }[] 540 + }> { 541 + const res = await this._client.call('com.atproto.repo.listRecords', { 542 + collection: 'pub.leaflet.poll.definition', 543 + ...params, 544 + }) 545 + return res.data 546 + } 547 + 548 + async get( 549 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 550 + ): Promise<{ 551 + uri: string 552 + cid: string 553 + value: PubLeafletPollDefinition.Record 554 + }> { 555 + const res = await this._client.call('com.atproto.repo.getRecord', { 556 + collection: 'pub.leaflet.poll.definition', 557 + ...params, 558 + }) 559 + return res.data 560 + } 561 + 562 + async create( 563 + params: OmitKey< 564 + ComAtprotoRepoCreateRecord.InputSchema, 565 + 'collection' | 'record' 566 + >, 567 + record: Un$Typed<PubLeafletPollDefinition.Record>, 568 + headers?: Record<string, string>, 569 + ): Promise<{ uri: string; cid: string }> { 570 + const collection = 'pub.leaflet.poll.definition' 571 + const res = await this._client.call( 572 + 'com.atproto.repo.createRecord', 573 + undefined, 574 + { collection, ...params, record: { ...record, $type: collection } }, 575 + { encoding: 'application/json', headers }, 576 + ) 577 + return res.data 578 + } 579 + 580 + async put( 581 + params: OmitKey< 582 + ComAtprotoRepoPutRecord.InputSchema, 583 + 'collection' | 'record' 584 + >, 585 + record: Un$Typed<PubLeafletPollDefinition.Record>, 586 + headers?: Record<string, string>, 587 + ): Promise<{ uri: string; cid: string }> { 588 + const collection = 'pub.leaflet.poll.definition' 589 + const res = await this._client.call( 590 + 'com.atproto.repo.putRecord', 591 + undefined, 592 + { collection, ...params, record: { ...record, $type: collection } }, 593 + { encoding: 'application/json', headers }, 594 + ) 595 + return res.data 596 + } 597 + 598 + async delete( 599 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 600 + headers?: Record<string, string>, 601 + ): Promise<void> { 602 + await this._client.call( 603 + 'com.atproto.repo.deleteRecord', 604 + undefined, 605 + { collection: 'pub.leaflet.poll.definition', ...params }, 606 + { headers }, 607 + ) 608 + } 609 + } 610 + 611 + export class PubLeafletPollVoteRecord { 612 + _client: XrpcClient 613 + 614 + constructor(client: XrpcClient) { 615 + this._client = client 616 + } 617 + 618 + async list( 619 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 620 + ): Promise<{ 621 + cursor?: string 622 + records: { uri: string; value: PubLeafletPollVote.Record }[] 623 + }> { 624 + const res = await this._client.call('com.atproto.repo.listRecords', { 625 + collection: 'pub.leaflet.poll.vote', 626 + ...params, 627 + }) 628 + return res.data 629 + } 630 + 631 + async get( 632 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 633 + ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> { 634 + const res = await this._client.call('com.atproto.repo.getRecord', { 635 + collection: 'pub.leaflet.poll.vote', 636 + ...params, 637 + }) 638 + return res.data 639 + } 640 + 641 + async create( 642 + params: OmitKey< 643 + ComAtprotoRepoCreateRecord.InputSchema, 644 + 'collection' | 'record' 645 + >, 646 + record: Un$Typed<PubLeafletPollVote.Record>, 647 + headers?: Record<string, string>, 648 + ): Promise<{ uri: string; cid: string }> { 649 + const collection = 'pub.leaflet.poll.vote' 650 + const res = await this._client.call( 651 + 'com.atproto.repo.createRecord', 652 + undefined, 653 + { collection, ...params, record: { ...record, $type: collection } }, 654 + { encoding: 'application/json', headers }, 655 + ) 656 + return res.data 657 + } 658 + 659 + async put( 660 + params: OmitKey< 661 + ComAtprotoRepoPutRecord.InputSchema, 662 + 'collection' | 'record' 663 + >, 664 + record: Un$Typed<PubLeafletPollVote.Record>, 665 + headers?: Record<string, string>, 666 + ): Promise<{ uri: string; cid: string }> { 667 + const collection = 'pub.leaflet.poll.vote' 668 + const res = await this._client.call( 669 + 'com.atproto.repo.putRecord', 670 + undefined, 671 + { collection, ...params, record: { ...record, $type: collection } }, 672 + { encoding: 'application/json', headers }, 673 + ) 674 + return res.data 675 + } 676 + 677 + async delete( 678 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 679 + headers?: Record<string, string>, 680 + ): Promise<void> { 681 + await this._client.call( 682 + 'com.atproto.repo.deleteRecord', 683 + undefined, 684 + { collection: 'pub.leaflet.poll.vote', ...params }, 685 + { headers }, 686 + ) 505 687 } 506 688 } 507 689
+87
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', ··· 1473 1489 'lex:pub.leaflet.blocks.horizontalRule', 1474 1490 'lex:pub.leaflet.blocks.bskyPost', 1475 1491 'lex:pub.leaflet.blocks.page', 1492 + 'lex:pub.leaflet.blocks.poll', 1476 1493 ], 1477 1494 }, 1478 1495 alignment: { ··· 1526 1543 }, 1527 1544 }, 1528 1545 }, 1546 + PubLeafletPollDefinition: { 1547 + lexicon: 1, 1548 + id: 'pub.leaflet.poll.definition', 1549 + defs: { 1550 + main: { 1551 + type: 'record', 1552 + key: 'tid', 1553 + description: 'Record declaring a poll', 1554 + record: { 1555 + type: 'object', 1556 + required: ['name', 'options'], 1557 + properties: { 1558 + name: { 1559 + type: 'string', 1560 + maxLength: 500, 1561 + maxGraphemes: 100, 1562 + }, 1563 + options: { 1564 + type: 'array', 1565 + items: { 1566 + type: 'ref', 1567 + ref: 'lex:pub.leaflet.poll.definition#option', 1568 + }, 1569 + }, 1570 + endDate: { 1571 + type: 'string', 1572 + format: 'datetime', 1573 + }, 1574 + }, 1575 + }, 1576 + }, 1577 + option: { 1578 + type: 'object', 1579 + properties: { 1580 + text: { 1581 + type: 'string', 1582 + maxLength: 500, 1583 + maxGraphemes: 50, 1584 + }, 1585 + }, 1586 + }, 1587 + }, 1588 + }, 1589 + PubLeafletPollVote: { 1590 + lexicon: 1, 1591 + id: 'pub.leaflet.poll.vote', 1592 + defs: { 1593 + main: { 1594 + type: 'record', 1595 + key: 'tid', 1596 + description: 'Record declaring a vote on a poll', 1597 + record: { 1598 + type: 'object', 1599 + required: ['poll', 'option'], 1600 + properties: { 1601 + poll: { 1602 + type: 'ref', 1603 + ref: 'lex:com.atproto.repo.strongRef', 1604 + }, 1605 + option: { 1606 + type: 'string', 1607 + }, 1608 + }, 1609 + }, 1610 + }, 1611 + }, 1612 + }, 1529 1613 PubLeafletPublication: { 1530 1614 lexicon: 1, 1531 1615 id: 'pub.leaflet.publication', ··· 1867 1951 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1868 1952 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1869 1953 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 1954 + PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 1870 1955 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1871 1956 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1872 1957 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', ··· 1874 1959 PubLeafletDocument: 'pub.leaflet.document', 1875 1960 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 1876 1961 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1962 + PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 1963 + PubLeafletPollVote: 'pub.leaflet.poll.vote', 1877 1964 PubLeafletPublication: 'pub.leaflet.publication', 1878 1965 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1879 1966 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 + }
+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 + }
+27
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": "string" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+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",
+2 -1
lexicons/src/polls/index.ts
··· 12 12 type: "object", 13 13 required: ["name", "options"], 14 14 properties: { 15 + name: { type: "string", maxLength: 500, maxGraphemes: 100 }, 15 16 options: { type: "array", items: { type: "ref", ref: "#option" } }, 16 17 endDate: { type: "string", format: "datetime" }, 17 18 }, ··· 36 37 description: "Record declaring a vote on a poll", 37 38 record: { 38 39 type: "object", 39 - required: ["poll"], 40 + required: ["poll", "option"], 40 41 properties: { 41 42 poll: { type: "ref", ref: "com.atproto.repo.strongRef" }, 42 43 option: { type: "string" },
+66
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 + option: string 62 + poll_cid: string 63 + poll_uri: string 64 + record: Json 65 + uri: string 66 + voter_did: string 67 + } 68 + Insert: { 69 + indexed_at?: string 70 + option: string 71 + poll_cid: string 72 + poll_uri: string 73 + record: Json 74 + uri: string 75 + voter_did: string 76 + } 77 + Update: { 78 + indexed_at?: string 79 + option?: string 80 + poll_cid?: string 81 + poll_uri?: string 82 + record?: Json 83 + uri?: string 84 + voter_did?: string 85 + } 86 + Relationships: [ 87 + { 88 + foreignKeyName: "atp_poll_votes_poll_uri_fkey" 89 + columns: ["poll_uri"] 90 + isOneToOne: false 91 + referencedRelation: "atp_poll_records" 92 + referencedColumns: ["uri"] 93 + }, 94 + { 95 + foreignKeyName: "atp_poll_votes_voter_did_fkey" 96 + columns: ["voter_did"] 97 + isOneToOne: false 98 + referencedRelation: "bsky_profiles" 99 + referencedColumns: ["did"] 100 + }, 101 + ] 102 + } 37 103 bsky_follows: { 38 104 Row: { 39 105 follows: string