From 0fd0b80a3bc2e678f59892b00b92c63e77a21f3c Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 18:30:51 -0400 Subject: [PATCH] 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 --- actions/publishToPublication.ts | 75 ++++- .../[did]/[publication]/[rkey]/CanvasPage.tsx | 10 + .../[rkey]/Interactions/Quotes.tsx | 1 + .../[rkey]/LinearDocumentPage.tsx | 4 + .../[publication]/[rkey]/PostContent.tsx | 24 ++ .../[did]/[publication]/[rkey]/PostPages.tsx | 6 + .../[rkey]/PublishedPageBlock.tsx | 2 + .../[rkey]/PublishedPollBlock.tsx | 278 ++++++++++++++++++ .../[publication]/[rkey]/fetchPollData.ts | 25 ++ app/lish/[did]/[publication]/[rkey]/page.tsx | 12 + .../[rkey]/voteOnPublishedPoll.ts | 64 ++++ appview/index.ts | 41 +++ components/Blocks/BlockCommands.tsx | 1 - components/Blocks/PollBlock.tsx | 13 +- components/Blocks/PublicationPollBlock.tsx | 187 ++++++++++++ drizzle/relations.ts | 13 +- drizzle/schema.ts | 25 +- lexicons/api/index.ts | 182 ++++++++++++ lexicons/api/lexicons.ts | 90 ++++++ lexicons/api/types/pub/leaflet/blocks/poll.ts | 31 ++ .../types/pub/leaflet/pages/linearDocument.ts | 2 + .../api/types/pub/leaflet/poll/definition.ts | 48 +++ lexicons/api/types/pub/leaflet/poll/vote.ts | 33 +++ lexicons/build.ts | 2 + lexicons/pub/leaflet/blocks/poll.json | 18 ++ .../pub/leaflet/pages/linearDocument.json | 3 +- lexicons/pub/leaflet/poll/definition.json | 46 +++ lexicons/pub/leaflet/poll/vote.json | 30 ++ lexicons/src/blocks.ts | 16 + lexicons/src/polls/index.ts | 48 +++ supabase/database.types.ts | 56 ++++ 31 files changed, 1371 insertions(+), 15 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/fetchPollData.ts create mode 100644 app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts create mode 100644 components/Blocks/PublicationPollBlock.tsx create mode 100644 lexicons/api/types/pub/leaflet/blocks/poll.ts create mode 100644 lexicons/api/types/pub/leaflet/poll/definition.ts create mode 100644 lexicons/api/types/pub/leaflet/poll/vote.ts create mode 100644 lexicons/pub/leaflet/blocks/poll.json create mode 100644 lexicons/pub/leaflet/poll/definition.json create mode 100644 lexicons/pub/leaflet/poll/vote.json create mode 100644 lexicons/src/polls/index.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 16fb7ebc..37cb1261 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -22,6 +22,8 @@ import { PubLeafletBlocksBlockquote, PubLeafletBlocksIframe, PubLeafletBlocksPage, + PubLeafletBlocksPoll, + PubLeafletPollDefinition, } from "lexicons/api"; import { Block } from "components/Blocks/Block"; import { TID } from "@atproto/common"; @@ -79,6 +81,7 @@ export async function publishToPublication({ facts, agent, root_entity, + credentialSession.did!, ); let existingRecord = @@ -148,6 +151,7 @@ async function processBlocksToPages( facts: Fact[], agent: AtpBaseClient, root_entity: string, + did: string, ) { let scan = scanIndexLocal(facts); let pages: { @@ -161,7 +165,7 @@ async function processBlocksToPages( let firstEntity = scan.eav(root_entity, "root/page")?.[0]; if (!firstEntity) throw new Error("No root page"); let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); - let b = await blocksToRecord(blocks); + let b = await blocksToRecord(blocks, did); return { firstPageBlocks: b, pages }; async function uploadImage(src: string) { @@ -175,6 +179,7 @@ async function processBlocksToPages( } async function blocksToRecord( blocks: Block[], + did: string, ): Promise { let parsedBlocks = parseBlocksToList(blocks); return ( @@ -190,7 +195,7 @@ async function processBlocksToPages( : alignmentValue === "right" ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" : undefined; - let b = await blockToRecord(blockOrList.block); + let b = await blockToRecord(blockOrList.block, did); if (!b) return []; let block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", @@ -203,7 +208,7 @@ async function processBlocksToPages( $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", - children: await childrenToRecord(blockOrList.children), + children: await childrenToRecord(blockOrList.children, did), }, }; return [block]; @@ -213,23 +218,23 @@ async function processBlocksToPages( ).flat(); } - async function childrenToRecord(children: List[]) { + async function childrenToRecord(children: List[], did: string) { return ( await Promise.all( children.map(async (child) => { - let content = await blockToRecord(child.block); + let content = await blockToRecord(child.block, did); if (!content) return []; let record: PubLeafletBlocksUnorderedList.ListItem = { $type: "pub.leaflet.blocks.unorderedList#listItem", content, - children: await childrenToRecord(child.children), + children: await childrenToRecord(child.children, did), }; return record; }), ) ).flat(); } - async function blockToRecord(b: Block) { + async function blockToRecord(b: Block, did: string) { const getBlockContent = (b: string) => { let [content] = scan.eav(b, "block/text"); if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; @@ -247,7 +252,7 @@ async function processBlocksToPages( let [pageType] = scan.eav(page.data.value, "page/type"); if (pageType?.data.value === "canvas") { - let canvasBlocks = await canvasBlocksToRecord(page.data.value); + let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); pages.push({ id: page.data.value, blocks: canvasBlocks, @@ -257,7 +262,7 @@ async function processBlocksToPages( let blocks = getBlocksWithTypeLocal(facts, page.data.value); pages.push({ id: page.data.value, - blocks: await blocksToRecord(blocks), + blocks: await blocksToRecord(blocks, did), type: "doc", }); } @@ -386,11 +391,61 @@ async function processBlocksToPages( }; return block; } + if (b.type === "poll") { + // Get poll options from the entity + let pollOptions = scan.eav(b.value, "poll/options"); + let options: PubLeafletPollDefinition.Option[] = pollOptions.map( + (opt) => { + let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; + return { + $type: "pub.leaflet.poll.definition#option", + text: optionName?.data.value || "", + }; + }, + ); + + // Create the poll definition record + let pollRecord: PubLeafletPollDefinition.Record = { + $type: "pub.leaflet.poll.definition", + name: "Poll", // Default name, can be customized + options, + }; + + // Upload the poll record + let { data: pollResult } = await agent.com.atproto.repo.putRecord({ + //use the entity id as the rkey so we can associate it in the editor + rkey: b.value, + repo: did, + collection: pollRecord.$type, + record: pollRecord, + validate: false, + }); + + // Optimistically write poll definition to database + console.log( + await supabaseServerClient.from("atp_poll_records").upsert({ + uri: pollResult.uri, + cid: pollResult.cid, + record: pollRecord as Json, + }), + ); + + // Return a poll block with reference to the poll record + let block: $Typed = { + $type: "pub.leaflet.blocks.poll", + pollRef: { + uri: pollResult.uri, + cid: pollResult.cid, + }, + }; + return block; + } return; } async function canvasBlocksToRecord( pageID: string, + did: string, ): Promise { let canvasBlocks = scan.eav(pageID, "canvas/block"); return ( @@ -411,7 +466,7 @@ async function processBlocksToPages( factID: canvasBlock.id, }; - let content = await blockToRecord(block); + let content = await blockToRecord(block, did); if (!content) return null; // Get canvas-specific properties diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index f7f1903a..3d6024cd 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -20,6 +20,7 @@ import { Popover } from "components/Popover"; import { InfoSmall } from "components/Icons/InfoSmall"; import { PostHeader } from "./PostHeader/PostHeader"; import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { PollData } from "./fetchPollData"; export function CanvasPage({ document, @@ -30,6 +31,7 @@ export function CanvasPage({ pubRecord, prerenderedCodeBlocks, bskyPostData, + pollData, document_uri, pageId, pageOptions, @@ -44,6 +46,7 @@ export function CanvasPage({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; preferences: { showComments?: boolean }; pageId?: string; pageOptions?: React.ReactNode; @@ -79,6 +82,7 @@ export function CanvasPage({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} + pollData={pollData} pageId={pageId} pages={pages} /> @@ -92,11 +96,13 @@ function CanvasContent({ prerenderedCodeBlocks, bskyPostData, pageId, + pollData, pages, }: { blocks: PubLeafletPagesCanvas.Block[]; did: string; prerenderedCodeBlocks?: Map; + pollData: PollData[]; bskyPostData: AppBskyFeedDefs.PostView[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; @@ -127,6 +133,7 @@ function CanvasContent({ key={index} canvasBlock={canvasBlock} did={did} + pollData={pollData} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} pageId={pageId} @@ -145,6 +152,7 @@ function CanvasBlock({ did, prerenderedCodeBlocks, bskyPostData, + pollData, pageId, pages, index, @@ -153,6 +161,7 @@ function CanvasBlock({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; index: number; @@ -178,6 +187,7 @@ function CanvasBlock({ >
; bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; preferences: { showComments?: boolean }; pageId?: string; pageOptions?: React.ReactNode; @@ -81,6 +84,7 @@ export function LinearDocumentPage({ /> )} ; bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { return ( @@ -67,6 +72,7 @@ export function PostContent({ index={[index]} preview={preview} prerenderedCodeBlocks={prerenderedCodeBlocks} + pollData={pollData} /> ); })} @@ -85,6 +91,7 @@ export let Block = ({ bskyPostData, pageId, pages, + pollData, }: { pageId?: string; preview?: boolean; @@ -96,6 +103,7 @@ export let Block = ({ previousBlock?: PubLeafletPagesLinearDocument.Block; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; }) => { let b = block; let blockProps = { @@ -174,11 +182,24 @@ export let Block = ({ case PubLeafletBlocksHorizontalRule.isMain(b.block): { return
; } + case PubLeafletBlocksPoll.isMain(b.block): { + let { cid, uri } = b.block.pollRef; + const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); + if (!pollVoteData) return null; + return ( + + ); + } case PubLeafletBlocksUnorderedList.isMain(b.block): { return (
    {b.block.children.map((child, i) => ( (
    ; bskyPostData: AppBskyFeedDefs.PostView[]; preferences: { showComments?: boolean }; + pollData: PollData[]; }) { let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); @@ -137,6 +140,7 @@ export function PostPages({ did={did} profile={profile} fullPageScroll={fullPageScroll} + pollData={pollData} preferences={preferences} pubRecord={pubRecord} prerenderedCodeBlocks={prerenderedCodeBlocks} @@ -186,6 +190,7 @@ export function PostPages({ profile={profile} pubRecord={pubRecord} prerenderedCodeBlocks={prerenderedCodeBlocks} + pollData={pollData} bskyPostData={bskyPostData} document_uri={document_uri} pageId={page.id} @@ -205,6 +210,7 @@ export function PostPages({ did={did} preferences={preferences} pubRecord={pubRecord} + pollData={pollData} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} document_uri={document_uri} diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index 9d83d4ca..aad2a8d5 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -178,6 +178,7 @@ export function PagePreview(props: { /> )}
    { + try { + const record = voteRecord as PubLeafletPollVote.Record; + return record.option && record.option.length > 0 ? record.option[0] : null; + } catch { + return null; + } +}; + +export const PublishedPollBlock = (props: { + block: PubLeafletBlocksPoll.Main; + pollData: PollData; + className?: string; +}) => { + const { identity } = useIdentityData(); + const [selectedOption, setSelectedOption] = useState(null); + const [isVoting, setIsVoting] = useState(false); + const [showResults, setShowResults] = useState(false); + const [optimisticVote, setOptimisticVote] = useState<{ + option: string; + voter_did: string; + } | null>(null); + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; + let [isClient, setIsClient] = useState(false); + useEffect(() => { + setIsClient(true); + }, []); + + const handleVote = async () => { + if (!selectedOption || !identity?.atp_did) return; + + setIsVoting(true); + + // Optimistically add the vote + setOptimisticVote({ + option: selectedOption, + voter_did: identity.atp_did, + }); + setShowResults(true); + + try { + const result = await voteOnPublishedPoll( + props.block.pollRef.uri, + props.block.pollRef.cid, + selectedOption, + ); + + if (!result.success) { + console.error("Failed to vote:", result.error); + // Revert optimistic update on failure + setOptimisticVote(null); + setShowResults(false); + } + } catch (error) { + console.error("Failed to vote:", error); + // Revert optimistic update on failure + setOptimisticVote(null); + setShowResults(false); + } finally { + setIsVoting(false); + } + }; + + const hasVoted = + !!identity?.atp_did && + (!!props.pollData?.atp_poll_votes.find( + (v) => v.voter_did === identity?.atp_did, + ) || + !!optimisticVote); + let isCreator = + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); + const displayResults = showResults || hasVoted; + + return ( +
    + {displayResults ? ( + <> + + {isCreator && !hasVoted && ( +
    + +
    + )} + + ) : ( + <> + {pollRecord.options.map((option, index) => ( + setSelectedOption(index.toString())} + disabled={!identity?.atp_did} + /> + ))} +
    +
    + {isCreator && ( + + )} +
    + {identity?.atp_did ? ( + + {isVoting ? "Voting..." : "Vote!"} + + ) : ( + + Login to vote + + } + > + {isClient && ( + + )} + + )} +
    + + )} +
    + ); +}; + +const PollOptionButton = (props: { + option: PubLeafletPollDefinition.Option; + optionIndex: string; + selected: boolean; + onSelect: () => void; + disabled?: boolean; +}) => { + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; + + return ( +
    + + {props.option.text} + +
    + ); +}; + +const PollResults = (props: { + pollData: PollData; + hasVoted: boolean; + setShowResults: (show: boolean) => void; + optimisticVote: { option: string; voter_did: string } | null; +}) => { + // Merge optimistic vote with actual votes + const allVotes = props.optimisticVote + ? [ + ...props.pollData.atp_poll_votes, + { + voter_did: props.optimisticVote.voter_did, + record: { + $type: "pub.leaflet.poll.vote", + option: [props.optimisticVote.option], + }, + }, + ] + : props.pollData.atp_poll_votes; + + const totalVotes = allVotes.length || 0; + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; + let optionsWithCount = pollRecord.options.map((o, index) => ({ + ...o, + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), + })); + + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); + return ( + <> + {pollRecord.options.map((option, index) => { + const votes = allVotes.filter( + (v) => getVoteOption(v.record) === index.toString(), + ).length; + const isWinner = totalVotes > 0 && votes === highestVotes; + + return ( + + ); + })} + + ); +}; + +const PollResult = (props: { + option: PubLeafletPollDefinition.Option; + votes: number; + totalVotes: number; + winner: boolean; +}) => { + return ( +
    +
    +
    {props.option.text}
    +
    {props.votes}
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/fetchPollData.ts b/app/lish/[did]/[publication]/[rkey]/fetchPollData.ts new file mode 100644 index 00000000..26311d55 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/fetchPollData.ts @@ -0,0 +1,25 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { Json } from "supabase/database.types"; +import { supabaseServerClient } from "supabase/serverClient"; + +export type PollData = { + uri: string; + cid: string; + record: Json; + atp_poll_votes: { record: Json; voter_did: string }[]; +}; + +export async function fetchPollData(pollUris: string[]): Promise { + // Get current user's identity to check if they've voted + const identity = await getIdentityData(); + const userDid = identity?.atp_did; + + const { data } = await supabaseServerClient + .from("atp_poll_records") + .select(`*, atp_poll_votes(*)`) + .in("uri", pollUris); + + return data || []; +} diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index 2b92129d..362feebd 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -20,6 +20,7 @@ import { PostPageContextProvider } from "./PostPageContext"; import { PostPages } from "./PostPages"; import { extractCodeBlocks } from "./extractCodeBlocks"; import { LeafletLayout } from "components/LeafletLayout"; +import { fetchPollData } from "./fetchPollData"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -115,6 +116,16 @@ export default async function Post(props: { { headers: {} }, ) : { data: { posts: [] } }; + + // Extract poll blocks and fetch vote data + let pollBlocks = record.pages.flatMap((p) => { + let page = p as PubLeafletPagesLinearDocument.Main; + return page.blocks?.filter( + (b) => b.block.$type === ids.PubLeafletBlocksPoll, + ) || []; + }); + let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri)); + let firstPage = record.pages[0]; let blocks: PubLeafletPagesLinearDocument.Block[] = []; if (PubLeafletPagesLinearDocument.isMain(firstPage)) { @@ -165,6 +176,7 @@ export default async function Post(props: { did={did} blocks={blocks} prerenderedCodeBlocks={prerenderedCodeBlocks} + pollData={pollData} /> diff --git a/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts b/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts new file mode 100644 index 00000000..e04d6161 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts @@ -0,0 +1,64 @@ +"use server"; + +import { createOauthClient } from "src/atproto-oauth"; +import { getIdentityData } from "actions/getIdentityData"; +import { AtpBaseClient, AtUri } from "@atproto/api"; +import { PubLeafletPollVote } from "lexicons/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; +import { TID } from "@atproto/common"; + +export async function voteOnPublishedPoll( + pollUri: string, + pollCid: string, + selectedOption: string, +): Promise<{ success: boolean; error?: string }> { + try { + const identity = await getIdentityData(); + + if (!identity?.atp_did) { + return { success: false, error: "Not authenticated" }; + } + + const oauthClient = await createOauthClient(); + const session = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient(session.fetchHandler.bind(session)); + + const voteRecord: PubLeafletPollVote.Record = { + $type: "pub.leaflet.poll.vote", + poll: { + uri: pollUri, + cid: pollCid, + }, + option: [selectedOption], + }; + + const rkey = TID.nextStr(); + const voteUri = AtUri.make(identity.atp_did, "pub.leaflet.poll.vote", rkey); + + // Write to database optimistically before creating the record + await supabaseServerClient.from("atp_poll_votes").upsert({ + uri: voteUri.toString(), + voter_did: identity.atp_did, + poll_uri: pollUri, + poll_cid: pollCid, + record: voteRecord as unknown as Json, + }); + + // Create the record on ATP + await agent.com.atproto.repo.createRecord({ + repo: identity.atp_did, + collection: "pub.leaflet.poll.vote", + rkey, + record: voteRecord, + }); + + return { success: true }; + } catch (error) { + console.error("Failed to vote:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to vote", + }; + } +} diff --git a/appview/index.ts b/appview/index.ts index a4926f30..d7eee4de 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -9,6 +9,8 @@ import { PubLeafletGraphSubscription, PubLeafletPublication, PubLeafletComment, + PubLeafletPollVote, + PubLeafletPollDefinition, } from "lexicons/api"; import { AppBskyEmbedExternal, @@ -44,6 +46,8 @@ async function main() { ids.PubLeafletPublication, ids.PubLeafletGraphSubscription, ids.PubLeafletComment, + ids.PubLeafletPollVote, + ids.PubLeafletPollDefinition, // ids.AppBskyActorProfile, "app.bsky.feed.post", ], @@ -169,6 +173,43 @@ async function handleEvent(evt: Event) { .eq("uri", evt.uri.toString()); } } + if (evt.collection === ids.PubLeafletPollVote) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletPollVote.validateRecord(evt.record); + if (!record.success) return; + let { error } = await supabase.from("atp_poll_votes").upsert({ + uri: evt.uri.toString(), + voter_did: evt.did, + poll_uri: record.value.poll.uri, + poll_cid: record.value.poll.cid, + record: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase + .from("atp_poll_votes") + .delete() + .eq("uri", evt.uri.toString()); + } + } + if (evt.collection === ids.PubLeafletPollDefinition) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletPollDefinition.validateRecord(evt.record); + if (!record.success) return; + let { error } = await supabase.from("atp_poll_records").upsert({ + uri: evt.uri.toString(), + cid: evt.cid.toString(), + record: record.value as Json, + }); + if (error) console.log("Error upserting poll definition:", error); + } + if (evt.event === "delete") { + await supabase + .from("atp_poll_records") + .delete() + .eq("uri", evt.uri.toString()); + } + } if (evt.collection === ids.PubLeafletGraphSubscription) { if (evt.event === "create" || evt.event === "update") { let record = PubLeafletGraphSubscription.validateRecord(evt.record); diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index 467eb02f..bff5c74d 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -235,7 +235,6 @@ export const blockCommands: Command[] = [ name: "Poll", icon: , type: "block", - hiddenInPublication: true, onSelect: async (rep, props, um) => { let entity = await createBlockWithType(rep, props, "poll"); let pollOptionEntity = v7(); diff --git a/components/Blocks/PollBlock.tsx b/components/Blocks/PollBlock.tsx index 66051152..1a8ba4a8 100644 --- a/components/Blocks/PollBlock.tsx +++ b/components/Blocks/PollBlock.tsx @@ -8,12 +8,16 @@ import { useEntitySetContext } from "components/EntitySetProvider"; import { theme } from "tailwind.config"; import { useEntity, useReplicache } from "src/replicache"; import { v7 } from "uuid"; -import { usePollData } from "components/PageSWRDataProvider"; +import { + useLeafletPublicationData, + usePollData, +} from "components/PageSWRDataProvider"; import { voteOnPoll } from "actions/pollActions"; import { create } from "zustand"; import { elementId } from "src/utils/elementId"; import { CheckTiny } from "components/Icons/CheckTiny"; import { CloseTiny } from "components/Icons/CloseTiny"; +import { PublicationPollBlock } from "./PublicationPollBlock"; export let usePollBlockUIState = create( () => @@ -21,7 +25,14 @@ export let usePollBlockUIState = create( [entity: string]: { state: "editing" | "voting" | "results" } | undefined; }, ); + export const PollBlock = (props: BlockProps) => { + let { data: pub } = useLeafletPublicationData(); + if (!pub) return ; + return ; +}; + +export const LeafletPollBlock = (props: BlockProps) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx new file mode 100644 index 00000000..6cdf6023 --- /dev/null +++ b/components/Blocks/PublicationPollBlock.tsx @@ -0,0 +1,187 @@ +import { useUIState } from "src/useUIState"; +import { BlockProps } from "./Block"; +import { useMemo } from "react"; +import { focusElement, AsyncValueInput } from "components/Input"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { useEntity, useReplicache } from "src/replicache"; +import { v7 } from "uuid"; +import { elementId } from "src/utils/elementId"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { useLeafletPublicationData } from "components/PageSWRDataProvider"; +import { + PubLeafletBlocksPoll, + PubLeafletDocument, + PubLeafletPagesLinearDocument, +} from "lexicons/api"; +import { ids } from "lexicons/api/lexicons"; + +/** + * PublicationPollBlock is used for editing polls in publication documents. + * It allows adding/editing options when the poll hasn't been published yet, + * but disables adding new options once the poll record exists (indicated by pollUri). + */ +export const PublicationPollBlock = (props: BlockProps) => { + let { data: publicationData } = useLeafletPublicationData(); + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + // Check if this poll has been published in a publication document + const isPublished = useMemo(() => { + if (!publicationData?.documents?.data) return false; + + const docRecord = publicationData.documents + .data as PubLeafletDocument.Record; + console.log(docRecord); + + // Search through all pages and blocks to find if this poll entity has been published + for (const page of docRecord.pages || []) { + if (page.$type === "pub.leaflet.pages.linearDocument") { + const linearPage = page as PubLeafletPagesLinearDocument.Main; + for (const blockWrapper of linearPage.blocks || []) { + if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) { + const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main; + console.log(pollBlock); + // Check if this poll's rkey matches our entity ID + const rkey = pollBlock.pollRef.uri.split("/").pop(); + if (rkey === props.entityID) { + return true; + } + } + } + } + } + return false; + }, [publicationData, props.entityID]); + + return ( +
    + +
    + ); +}; + +const EditPollForPublication = (props: { + entityID: string; + isPublished: boolean; +}) => { + let pollOptions = useEntity(props.entityID, "poll/options"); + let { rep } = useReplicache(); + let permission_set = useEntitySetContext(); + + return ( + <> + {props.isPublished && ( +
    + This poll has been published. You can't edit the options. +
    + )} + + {pollOptions.length === 0 && !props.isPublished && ( +
    + no options yet... +
    + )} + + {pollOptions.map((p) => ( + + ))} + + {!props.isPublished && permission_set.permissions.write && ( + + )} + + ); +}; + +const EditPollOptionForPublication = (props: { + entityID: string; + pollEntity: string; + disabled: boolean; + canDelete: boolean; +}) => { + let { rep } = useReplicache(); + let { permissions } = useEntitySetContext(); + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; + + return ( +
    + { + await rep?.mutate.assertFact([ + { + entity: props.entityID, + attribute: "poll-option/name", + data: { type: "string", value: e.currentTarget.value }, + }, + ]); + }} + onKeyDown={(e) => { + if ( + props.canDelete && + e.key === "Backspace" && + !e.currentTarget.value + ) { + e.preventDefault(); + rep?.mutate.removePollOption({ optionEntity: props.entityID }); + } + }} + /> + + {permissions.write && props.canDelete && ( + + )} +
    + ); +}; diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 07370757..ee948385 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -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"; +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"; export const publicationsRelations = relations(publications, ({one, many}) => ({ identity: one(identities, { @@ -182,6 +182,17 @@ export const email_subscriptions_to_entityRelations = relations(email_subscripti }), })); +export const atp_poll_votesRelations = relations(atp_poll_votes, ({one}) => ({ + atp_poll_record: one(atp_poll_records, { + fields: [atp_poll_votes.poll_uri], + references: [atp_poll_records.uri] + }), +})); + +export const atp_poll_recordsRelations = relations(atp_poll_records, ({many}) => ({ + atp_poll_votes: many(atp_poll_votes), +})); + export const bsky_followsRelations = relations(bsky_follows, ({one}) => ({ identity_follows: one(identities, { fields: [bsky_follows.follows], diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 2eeb931b..b270a464 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -204,13 +204,36 @@ export const documents = pgTable("documents", { indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }); +export const atp_poll_votes = pgTable("atp_poll_votes", { + uri: text("uri").primaryKey().notNull(), + record: jsonb("record").notNull(), + voter_did: text("voter_did").notNull(), + poll_uri: text("poll_uri").notNull().references(() => atp_poll_records.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + poll_cid: text("poll_cid").notNull(), + option: text("option").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + poll_uri_idx: index("atp_poll_votes_poll_uri_idx").on(table.poll_uri), + voter_did_idx: index("atp_poll_votes_voter_did_idx").on(table.voter_did), + } +}); + +export const atp_poll_records = pgTable("atp_poll_records", { + uri: text("uri").primaryKey().notNull(), + cid: text("cid").notNull(), + record: jsonb("record").notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + export const oauth_session_store = pgTable("oauth_session_store", { key: text("key").primaryKey().notNull(), session: jsonb("session").notNull(), }); export const bsky_follows = pgTable("bsky_follows", { - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), + identity: text("identity").default('').notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), follows: text("follows").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), }, (table) => { diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index d35e4744..15977768 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -32,6 +32,7 @@ import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' +import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -40,6 +41,8 @@ import * as PubLeafletDocument from './types/pub/leaflet/document' import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' +import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' +import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' import * as PubLeafletPublication from './types/pub/leaflet/publication' import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' @@ -68,6 +71,7 @@ export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' +export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -76,6 +80,8 @@ export * as PubLeafletDocument from './types/pub/leaflet/document' export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' +export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' +export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' export * as PubLeafletPublication from './types/pub/leaflet/publication' export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' @@ -385,6 +391,7 @@ export class PubLeafletNS { blocks: PubLeafletBlocksNS graph: PubLeafletGraphNS pages: PubLeafletPagesNS + poll: PubLeafletPollNS richtext: PubLeafletRichtextNS theme: PubLeafletThemeNS @@ -393,6 +400,7 @@ export class PubLeafletNS { this.blocks = new PubLeafletBlocksNS(client) this.graph = new PubLeafletGraphNS(client) this.pages = new PubLeafletPagesNS(client) + this.poll = new PubLeafletPollNS(client) this.richtext = new PubLeafletRichtextNS(client) this.theme = new PubLeafletThemeNS(client) this.comment = new PubLeafletCommentRecord(client) @@ -510,6 +518,180 @@ export class PubLeafletPagesNS { } } +export class PubLeafletPollNS { + _client: XrpcClient + definition: PubLeafletPollDefinitionRecord + vote: PubLeafletPollVoteRecord + + constructor(client: XrpcClient) { + this._client = client + this.definition = new PubLeafletPollDefinitionRecord(client) + this.vote = new PubLeafletPollVoteRecord(client) + } +} + +export class PubLeafletPollDefinitionRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletPollDefinition.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.poll.definition', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: PubLeafletPollDefinition.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.poll.definition', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.poll.definition' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.poll.definition' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.poll.definition', ...params }, + { headers }, + ) + } +} + +export class PubLeafletPollVoteRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletPollVote.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.poll.vote', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.poll.vote', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.poll.vote' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.poll.vote' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.poll.vote', ...params }, + { headers }, + ) + } +} + export class PubLeafletRichtextNS { _client: XrpcClient diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index e90f27f7..e14c622d 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1200,6 +1200,22 @@ export const schemaDict = { }, }, }, + PubLeafletBlocksPoll: { + lexicon: 1, + id: 'pub.leaflet.blocks.poll', + defs: { + main: { + type: 'object', + required: ['pollRef'], + properties: { + pollRef: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, + }, + }, + }, + }, PubLeafletBlocksText: { lexicon: 1, id: 'pub.leaflet.blocks.text', @@ -1574,6 +1590,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', 'lex:pub.leaflet.blocks.page', + 'lex:pub.leaflet.blocks.poll', ], }, alignment: { @@ -1627,6 +1644,76 @@ export const schemaDict = { }, }, }, + PubLeafletPollDefinition: { + lexicon: 1, + id: 'pub.leaflet.poll.definition', + defs: { + main: { + type: 'record', + key: 'tid', + description: 'Record declaring a poll', + record: { + type: 'object', + required: ['name', 'options'], + properties: { + name: { + type: 'string', + maxLength: 500, + maxGraphemes: 100, + }, + options: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:pub.leaflet.poll.definition#option', + }, + }, + endDate: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + option: { + type: 'object', + properties: { + text: { + type: 'string', + maxLength: 500, + maxGraphemes: 50, + }, + }, + }, + }, + }, + PubLeafletPollVote: { + lexicon: 1, + id: 'pub.leaflet.poll.vote', + defs: { + main: { + type: 'record', + key: 'tid', + description: 'Record declaring a vote on a poll', + record: { + type: 'object', + required: ['poll', 'option'], + properties: { + poll: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, + option: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, PubLeafletPublication: { lexicon: 1, id: 'pub.leaflet.publication', @@ -1968,6 +2055,7 @@ export const ids = { PubLeafletBlocksImage: 'pub.leaflet.blocks.image', PubLeafletBlocksMath: 'pub.leaflet.blocks.math', PubLeafletBlocksPage: 'pub.leaflet.blocks.page', + PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', @@ -1976,6 +2064,8 @@ export const ids = { PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', + PubLeafletPollDefinition: 'pub.leaflet.poll.definition', + PubLeafletPollVote: 'pub.leaflet.poll.vote', PubLeafletPublication: 'pub.leaflet.publication', PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', diff --git a/lexicons/api/types/pub/leaflet/blocks/poll.ts b/lexicons/api/types/pub/leaflet/blocks/poll.ts new file mode 100644 index 00000000..92860d86 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/poll.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' +import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.poll' + +export interface Main { + $type?: 'pub.leaflet.blocks.poll' + pollRef: ComAtprotoRepoStrongRef.Main +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
    (v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index 4fe84e8e..ed5785b2 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -21,6 +21,7 @@ import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' import type * as PubLeafletBlocksPage from '../blocks/page' +import type * as PubLeafletBlocksPoll from '../blocks/poll' const is$typed = _is$typed, validate = _validate @@ -57,6 +58,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | { $type: string } alignment?: | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' diff --git a/lexicons/api/types/pub/leaflet/poll/definition.ts b/lexicons/api/types/pub/leaflet/poll/definition.ts new file mode 100644 index 00000000..f30467e6 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/poll/definition.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.poll.definition' + +export interface Record { + $type: 'pub.leaflet.poll.definition' + name: string + options: Option[] + endDate?: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} + +export interface Option { + $type?: 'pub.leaflet.poll.definition#option' + text?: string +} + +const hashOption = 'option' + +export function isOption(v: V) { + return is$typed(v, id, hashOption) +} + +export function validateOption(v: V) { + return validate