diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index edd41056..f52cf0f1 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -44,6 +44,7 @@ export function PublishedPageLinkBlock(props: { bg-bg-page shadow-sm flex overflow-clip block-border + mt-1 mb-2 ${isOpen && "!border-tertiary"} `} onClick={(e) => { -- 2.51.2 From b3f489de564d061f514e2ea02c68ae67f0506452 Mon Sep 17 00:00:00 2001 From: celine Date: Tue, 21 Oct 2025 16:49:43 -0400 Subject: [PATCH 02/33] adjusting a loooot of spacing here and there --- .../[did]/[publication]/[rkey]/PostContent.tsx | 15 +++++++-------- app/lish/[did]/[publication]/[rkey]/PostPages.tsx | 2 +- .../[publication]/[rkey]/PublishBskyPostBlock.tsx | 7 ++++++- .../[publication]/[rkey]/PublishedPageBlock.tsx | 1 + components/DesktopFooter.tsx | 2 +- components/Pages/Page.tsx | 1 + 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 93ff8f34..a19a5458 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -123,12 +123,11 @@ let Block = ({ if (!alignment && PubLeafletBlocksImage.isMain(b.block)) alignment = "text-center justify-center"; - // non text blocks, they need this padding, pt-3 sm:pt-4, which is applied in each case let className = ` postBlockWrapper min-h-7 - pt-1 pb-2 - ${isList && "isListItem pb-0! "} + mt-1 mb-2 + ${isList && "isListItem mb-0! "} ${alignment} `; @@ -144,6 +143,7 @@ let Block = ({ parentPageId={pageId} did={did} bskyPostData={bskyPostData} + className={className} /> ); } @@ -151,7 +151,7 @@ let Block = ({ let uri = b.block.postRef.uri; let post = bskyPostData.find((p) => p.uri === uri); if (!post) return
no prefetched post rip
; - return ; + return ; } case PubLeafletBlocksIframe.isMain(b.block): { return ( @@ -200,7 +200,7 @@ let Block = ({ href={b.block.src} target="_blank" className={` - my-2 + ${className} externalLinkBlock flex relative group/linkBlock h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline hover:border-accent-contrast shadow-sm @@ -252,7 +252,7 @@ let Block = ({ alt={b.block.alt} height={b.block.aspectRatio?.height} width={b.block.aspectRatio?.width} - className={`pt-3! sm:pt-4! rounded-md ${className}`} + className={`rounded-lg border border-transparent ${className}`} src={blobRefToSrc(b.block.image.ref, did)} /> {b.block.alt && ( @@ -273,7 +273,7 @@ let Block = ({ } case PubLeafletBlocksBlockquote.isMain(b.block): { return ( - // highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. + // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
) : null; - return (

  • -
    +
    {identity && identity.atp_did === document.documents_in_publications[0]?.publications diff --git a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx index bb44240c..807e902d 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx @@ -16,7 +16,11 @@ import { } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; -export const PubBlueskyPostBlock = ({ post }: { post: PostView }) => { +export const PubBlueskyPostBlock = (props: { + post: PostView; + className: string; +}) => { + let post = props.post; switch (true) { case AppBskyFeedDefs.isBlockedPost(post) || AppBskyFeedDefs.isBlockedAuthor(post) || @@ -46,6 +50,7 @@ export const PubBlueskyPostBlock = ({ post }: { post: PostView }) => { return (
    { if (e.isDefaultPrevented()) return; diff --git a/components/DesktopFooter.tsx b/components/DesktopFooter.tsx index 21c02bb9..3675dd3a 100644 --- a/components/DesktopFooter.tsx +++ b/components/DesktopFooter.tsx @@ -16,7 +16,7 @@ export function DesktopPageFooter(props: { pageID: string }) { return ( {focusedEntity && focusedEntity.entityType === "block" && diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 2ec2016f..f0ae923d 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -133,6 +133,7 @@ export const PageWrapper = (props: { `} > {props.children} +
    {props.pageOptions} -- 2.51.2 From e7d1c9de3cd3704b30d3a9f18b3acba9e15f21e4 Mon Sep 17 00:00:00 2001 From: celine Date: Tue, 21 Oct 2025 16:51:08 -0400 Subject: [PATCH 03/33] rm some redundant margins --- app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index 849c39b7..07d203c4 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -44,7 +44,6 @@ export function PublishedPageLinkBlock(props: { bg-bg-page shadow-sm flex overflow-clip block-border - mt-1 mb-2 ${isOpen && "!border-tertiary"} ${props.className} `} -- 2.51.2 From a1ee677f4499819b303348073a8da50100b9972b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Oct 2025 23:40:49 -0400 Subject: [PATCH 04/33] hook rules --- components/Pages/Page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index f0ae923d..cda9e3f1 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -33,9 +33,8 @@ export function Page(props: { return focusedPageID === props.entityID; }); let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; - let canvasNarrow = - pageType === "canvas" && - useEntity(props.entityID, "canvas/narrow-width")?.data.value; + let canvasNarrow = useEntity(props.entityID, "canvas/narrow-width")?.data + .value; let cardBorderHidden = useCardBorderHidden(props.entityID); let drawerOpen = useDrawerOpen(props.entityID); return ( -- 2.51.2 From dfc1d59951b273d58139de6aac22d6dcaa978eaf Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 23 Oct 2025 17:51:07 -0400 Subject: [PATCH 05/33] add tables for polls --- .../20251023200453_atp_poll_votes.sql | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 supabase/migrations/20251023200453_atp_poll_votes.sql diff --git a/supabase/migrations/20251023200453_atp_poll_votes.sql b/supabase/migrations/20251023200453_atp_poll_votes.sql new file mode 100644 index 00000000..db0a5a1e --- /dev/null +++ b/supabase/migrations/20251023200453_atp_poll_votes.sql @@ -0,0 +1,123 @@ +create table "public"."atp_poll_votes" ( + "uri" text not null, + "record" jsonb not null, + "voter_did" text not null, + "poll_uri" text not null, + "poll_cid" text not null, + "option" text not null, + "indexed_at" timestamp with time zone not null default now() +); + +alter table "public"."atp_poll_votes" enable row level security; + +CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri); + +alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey"; + +CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri); + +CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did); + +grant delete on table "public"."atp_poll_votes" to "anon"; + +grant insert on table "public"."atp_poll_votes" to "anon"; + +grant references on table "public"."atp_poll_votes" to "anon"; + +grant select on table "public"."atp_poll_votes" to "anon"; + +grant trigger on table "public"."atp_poll_votes" to "anon"; + +grant truncate on table "public"."atp_poll_votes" to "anon"; + +grant update on table "public"."atp_poll_votes" to "anon"; + +grant delete on table "public"."atp_poll_votes" to "authenticated"; + +grant insert on table "public"."atp_poll_votes" to "authenticated"; + +grant references on table "public"."atp_poll_votes" to "authenticated"; + +grant select on table "public"."atp_poll_votes" to "authenticated"; + +grant trigger on table "public"."atp_poll_votes" to "authenticated"; + +grant truncate on table "public"."atp_poll_votes" to "authenticated"; + +grant update on table "public"."atp_poll_votes" to "authenticated"; + +grant delete on table "public"."atp_poll_votes" to "service_role"; + +grant insert on table "public"."atp_poll_votes" to "service_role"; + +grant references on table "public"."atp_poll_votes" to "service_role"; + +grant select on table "public"."atp_poll_votes" to "service_role"; + +grant trigger on table "public"."atp_poll_votes" to "service_role"; + +grant truncate on table "public"."atp_poll_votes" to "service_role"; + +grant update on table "public"."atp_poll_votes" to "service_role"; + +create table "public"."atp_poll_records" ( + "uri" text not null, + "cid" text not null, + "record" jsonb not null, + "created_at" timestamp with time zone not null default now() +); + + +alter table "public"."atp_poll_records" enable row level security; + +alter table "public"."bsky_follows" alter column "identity" set default ''::text; + +CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri); + +alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey"; + +alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey"; + +grant delete on table "public"."atp_poll_records" to "anon"; + +grant insert on table "public"."atp_poll_records" to "anon"; + +grant references on table "public"."atp_poll_records" to "anon"; + +grant select on table "public"."atp_poll_records" to "anon"; + +grant trigger on table "public"."atp_poll_records" to "anon"; + +grant truncate on table "public"."atp_poll_records" to "anon"; + +grant update on table "public"."atp_poll_records" to "anon"; + +grant delete on table "public"."atp_poll_records" to "authenticated"; + +grant insert on table "public"."atp_poll_records" to "authenticated"; + +grant references on table "public"."atp_poll_records" to "authenticated"; + +grant select on table "public"."atp_poll_records" to "authenticated"; + +grant trigger on table "public"."atp_poll_records" to "authenticated"; + +grant truncate on table "public"."atp_poll_records" to "authenticated"; + +grant update on table "public"."atp_poll_records" to "authenticated"; + +grant delete on table "public"."atp_poll_records" to "service_role"; + +grant insert on table "public"."atp_poll_records" to "service_role"; + +grant references on table "public"."atp_poll_records" to "service_role"; + +grant select on table "public"."atp_poll_records" to "service_role"; + +grant trigger on table "public"."atp_poll_records" to "service_role"; + +grant truncate on table "public"."atp_poll_records" to "service_role"; + +grant update on table "public"."atp_poll_records" to "service_role"; -- 2.51.2 From c1ad681300d19cc33879c21ec4f6e37b9e477eed Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 12:24:17 -0400 Subject: [PATCH 06/33] handle dropping images --- components/Blocks/Block.tsx | 20 +++++++- components/Blocks/ImageBlock.tsx | 71 ++++++++++++++++++---------- components/Blocks/index.tsx | 11 +++++ components/Blocks/useHandleDrop.ts | 74 ++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 components/Blocks/useHandleDrop.ts diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 5e88baf2..0d36c2af 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -7,6 +7,8 @@ import { useBlockMouseHandlers } from "./useBlockMouseHandlers"; import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; import { useLongPress } from "src/hooks/useLongPress"; import { focusBlock } from "src/utils/focusBlock"; +import { useHandleDrop } from "./useHandleDrop"; +import { useEntitySetContext } from "components/EntitySetProvider"; import { TextBlock } from "components/Blocks/TextBlock"; import { ImageBlock } from "./ImageBlock"; @@ -15,7 +17,6 @@ import { ExternalLinkBlock } from "./ExternalLinkBlock"; import { EmbedBlock } from "./EmbedBlock"; import { MailboxBlock } from "./MailboxBlock"; import { AreYouSure } from "./DeleteBlock"; -import { useEntitySetContext } from "components/EntitySetProvider"; import { useIsMobile } from "src/hooks/isMobile"; import { DateTimeBlock } from "./DateTimeBlock"; import { RSVPBlock } from "./RSVPBlock"; @@ -63,6 +64,12 @@ export const Block = memo(function Block( // and shared styling like padding and flex for list layouting let mouseHandlers = useBlockMouseHandlers(props); + let handleDrop = useHandleDrop({ + parent: props.parent, + position: props.position, + nextPosition: props.nextPosition, + }); + let entity_set = useEntitySetContext(); let { isLongPress, handlers } = useLongPress(() => { if (isTextBlock[props.type]) return; @@ -94,6 +101,17 @@ export const Block = memo(function Block( id={ !props.preview ? elementId.block(props.entityID).container : undefined } + onDragOver={ + !props.preview && entity_set.permissions.write + ? (e) => { + e.preventDefault(); + e.stopPropagation(); + } + : undefined + } + onDrop={ + !props.preview && entity_set.permissions.write ? handleDrop : undefined + } className={` blockWrapper relative flex flex-row gap-2 diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx index 45d3a4ed..f88e46b7 100644 --- a/components/Blocks/ImageBlock.tsx +++ b/components/Blocks/ImageBlock.tsx @@ -51,6 +51,34 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { } }, [isSelected, props.preview, props.entityID]); + const handleImageUpload = async (file: File) => { + if (!rep) return; + let entity = props.entityID; + if (!entity) { + entity = v7(); + await rep?.mutate.addBlock({ + parent: props.parent, + factID: v7(), + permission_set: entity_set.set, + type: "text", + position: generateKeyBetween( + props.position, + props.nextPosition, + ), + newEntityID: entity, + }); + } + await rep.mutate.assertFact({ + entity, + attribute: "block/type", + data: { type: "block-type-union", value: "image" }, + }); + await addImage(file, rep, { + entityID: entity, + attribute: "block/image", + }); + }; + if (!image) { if (!entity_set.permissions.write) return null; return ( @@ -65,6 +93,22 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} ${props.pageType === "canvas" && "bg-bg-page"}`} onMouseDown={(e) => e.preventDefault()} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onDrop={async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isLocked) return; + const files = e.dataTransfer.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + await handleImageUpload(file); + } + } + }} >
    { let file = e.currentTarget.files?.[0]; - if (!file || !rep) return; - let entity = props.entityID; - if (!entity) { - entity = v7(); - await rep?.mutate.addBlock({ - parent: props.parent, - factID: v7(), - permission_set: entity_set.set, - type: "text", - position: generateKeyBetween( - props.position, - props.nextPosition, - ), - newEntityID: entity, - }); - } - await rep.mutate.assertFact({ - entity, - attribute: "block/type", - data: { type: "block-type-union", value: "image" }, - }); - await addImage(file, rep, { - entityID: entity, - attribute: "block/image", - }); + if (!file) return; + await handleImageUpload(file); }} /> diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 8773948e..72166f0e 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -17,6 +17,7 @@ import { Block } from "./Block"; import { useEffect } from "react"; import { addShortcut } from "src/shortcuts"; import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; +import { useHandleDrop } from "./useHandleDrop"; export function Blocks(props: { entityID: string }) { let rep = useReplicache(); @@ -231,6 +232,11 @@ const BlockListBottom = (props: { }) => { let { rep } = useReplicache(); let entity_set = useEntitySetContext(); + let handleDrop = useHandleDrop({ + parent: props.entityID, + position: props.lastRootBlock?.position || null, + nextPosition: null, + }); if (!entity_set.permissions.write) return; return ( @@ -267,6 +273,11 @@ const BlockListBottom = (props: { }, 10); } }} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onDrop={handleDrop} /> ); }; diff --git a/components/Blocks/useHandleDrop.ts b/components/Blocks/useHandleDrop.ts new file mode 100644 index 00000000..4c3dbb3f --- /dev/null +++ b/components/Blocks/useHandleDrop.ts @@ -0,0 +1,74 @@ +import { useCallback } from "react"; +import { useReplicache } from "src/replicache"; +import { generateKeyBetween } from "fractional-indexing"; +import { addImage } from "src/utils/addImage"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { v7 } from "uuid"; + +export const useHandleDrop = (params: { + parent: string; + position: string | null; + nextPosition: string | null; +}) => { + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + + return useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!rep) return; + + const files = e.dataTransfer.files; + if (!files || files.length === 0) return; + + // Filter for image files only + const imageFiles = Array.from(files).filter((file) => + file.type.startsWith("image/"), + ); + + if (imageFiles.length === 0) return; + + let currentPosition = params.position; + + // Calculate positions for all images first + const imageBlocks = imageFiles.map((file) => { + const entity = v7(); + const position = generateKeyBetween( + currentPosition, + params.nextPosition, + ); + currentPosition = position; + return { file, entity, position }; + }); + + // Create all blocks in parallel + await Promise.all( + imageBlocks.map((block) => + rep.mutate.addBlock({ + parent: params.parent, + factID: v7(), + permission_set: entity_set.set, + type: "image", + position: block.position, + newEntityID: block.entity, + }), + ), + ); + + // Upload all images in parallel + await Promise.all( + imageBlocks.map((block) => + addImage(block.file, rep, { + entityID: block.entity, + attribute: "block/image", + }), + ), + ); + + return true; + }, + [rep, params.position, params.nextPosition, params.parent, entity_set.set], + ); +}; -- 2.51.2 From d0502b5875d7f0045702d36c9ba7284dd94b99f8 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 12:53:22 -0400 Subject: [PATCH 07/33] handle dropping images onto canvas --- components/Blocks/useHandleCanvasDrop.ts | 233 +++++++++++++++++++++++ components/Canvas.tsx | 14 ++ 2 files changed, 247 insertions(+) create mode 100644 components/Blocks/useHandleCanvasDrop.ts diff --git a/components/Blocks/useHandleCanvasDrop.ts b/components/Blocks/useHandleCanvasDrop.ts new file mode 100644 index 00000000..017e00a5 --- /dev/null +++ b/components/Blocks/useHandleCanvasDrop.ts @@ -0,0 +1,233 @@ +import { useCallback } from "react"; +import { useReplicache, useEntity } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { v7 } from "uuid"; +import { supabaseBrowserClient } from "supabase/browserClient"; +import { localImages } from "src/utils/addImage"; +import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; + +// Helper function to load image dimensions and thumbhash +const processImage = async ( + file: File, +): Promise<{ + width: number; + height: number; + thumbhash: string; +}> => { + // Load image to get dimensions + const img = new Image(); + const url = URL.createObjectURL(file); + + const dimensions = await new Promise<{ width: number; height: number }>( + (resolve, reject) => { + img.onload = () => { + resolve({ width: img.width, height: img.height }); + }; + img.onerror = reject; + img.src = url; + }, + ); + + // Generate thumbhash + const arrayBuffer = await file.arrayBuffer(); + const blob = new Blob([arrayBuffer], { type: file.type }); + const imageBitmap = await createImageBitmap(blob); + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d") as CanvasRenderingContext2D; + const maxDimension = 100; + let width = imageBitmap.width; + let height = imageBitmap.height; + + if (width > height) { + if (width > maxDimension) { + height *= maxDimension / width; + width = maxDimension; + } + } else { + if (height > maxDimension) { + width *= maxDimension / height; + height = maxDimension; + } + } + + canvas.width = width; + canvas.height = height; + context.drawImage(imageBitmap, 0, 0, width, height); + + const imageData = context.getImageData(0, 0, width, height); + const thumbhash = thumbHashToDataURL( + rgbaToThumbHash(imageData.width, imageData.height, imageData.data), + ); + + URL.revokeObjectURL(url); + + return { + width: dimensions.width, + height: dimensions.height, + thumbhash, + }; +}; + +export const useHandleCanvasDrop = (entityID: string) => { + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + let blocks = useEntity(entityID, "canvas/block"); + + return useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!rep) return; + + const files = e.dataTransfer.files; + if (!files || files.length === 0) return; + + // Filter for image files only + const imageFiles = Array.from(files).filter((file) => + file.type.startsWith("image/"), + ); + + if (imageFiles.length === 0) return; + + const parentRect = e.currentTarget.getBoundingClientRect(); + const dropX = Math.max(e.clientX - parentRect.left, 0); + const dropY = Math.max(e.clientY - parentRect.top, 0); + + const SPACING = 0; + const DEFAULT_WIDTH = 360; + + // Process all images to get dimensions and thumbhashes + const processedImages = await Promise.all( + imageFiles.map((file) => processImage(file)), + ); + + // Calculate grid dimensions based on image count + const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); + + // Calculate the width and height for each column and row + const colWidths: number[] = []; + const rowHeights: number[] = []; + + for (let i = 0; i < imageFiles.length; i++) { + const col = i % COLUMNS; + const row = Math.floor(i / COLUMNS); + const dims = processedImages[i]; + + // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio + const scale = DEFAULT_WIDTH / dims.width; + const scaledWidth = DEFAULT_WIDTH; + const scaledHeight = dims.height * scale; + + // Track max width for each column and max height for each row + colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); + rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); + } + + const client = supabaseBrowserClient(); + const cache = await caches.open("minilink-user-assets"); + + // Calculate positions and prepare data for all images + const imageBlocks = imageFiles.map((file, index) => { + const entity = v7(); + const fileID = v7(); + const row = Math.floor(index / COLUMNS); + const col = index % COLUMNS; + + // Calculate x position by summing all previous column widths + let x = dropX; + for (let c = 0; c < col; c++) { + x += colWidths[c] + SPACING; + } + + // Calculate y position by summing all previous row heights + let y = dropY; + for (let r = 0; r < row; r++) { + y += rowHeights[r] + SPACING; + } + + const url = client.storage + .from("minilink-user-assets") + .getPublicUrl(fileID).data.publicUrl; + + return { + file, + entity, + fileID, + url, + position: { x, y }, + dimensions: processedImages[index], + }; + }); + + // Create all blocks with image facts + for (const block of imageBlocks) { + // Add to cache for immediate display + await cache.put( + new URL(block.url + "?local"), + new Response(block.file, { + headers: { + "Content-Type": block.file.type, + "Content-Length": block.file.size.toString(), + }, + }), + ); + localImages.set(block.url, true); + + // Create canvas block + await rep.mutate.addCanvasBlock({ + newEntityID: block.entity, + parent: entityID, + position: block.position, + factID: v7(), + type: "image", + permission_set: entity_set.set, + }); + + // Add image fact with local version for immediate display + if (navigator.serviceWorker) { + await rep.mutate.assertFact({ + entity: block.entity, + attribute: "block/image", + data: { + fallback: block.dimensions.thumbhash, + type: "image", + local: rep.clientID, + src: block.url, + height: block.dimensions.height, + width: block.dimensions.width, + }, + }); + } + } + + // Upload all files to storage in parallel + await Promise.all( + imageBlocks.map(async (block) => { + await client.storage + .from("minilink-user-assets") + .upload(block.fileID, block.file, { + cacheControl: "public, max-age=31560000, immutable", + }); + + // Update fact with final version + await rep.mutate.assertFact({ + entity: block.entity, + attribute: "block/image", + data: { + fallback: block.dimensions.thumbhash, + type: "image", + src: block.url, + height: block.dimensions.height, + width: block.dimensions.width, + }, + }); + }), + ); + + return true; + }, + [rep, entityID, entity_set.set, blocks], + ); +}; diff --git a/components/Canvas.tsx b/components/Canvas.tsx index 8eea85d1..9baf9da4 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -14,6 +14,7 @@ import { Media } from "./Media"; import { TooltipButton } from "./Buttons"; import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; import { AddSmall } from "./Icons/AddSmall"; +import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; export function Canvas(props: { entityID: string; preview?: boolean }) { let entity_set = useEntitySetContext(); @@ -69,6 +70,8 @@ export function CanvasContent(props: { entityID: string; preview?: boolean }) { let { rep } = useReplicache(); let entity_set = useEntitySetContext(); let height = Math.max(...blocks.map((f) => f.data.position.y), 0); + let handleDrop = useHandleCanvasDrop(props.entityID); + return (
    { @@ -106,6 +109,17 @@ export function CanvasContent(props: { entityID: string; preview?: boolean }) { ); } }} + onDragOver={ + !props.preview && entity_set.permissions.write + ? (e) => { + e.preventDefault(); + e.stopPropagation(); + } + : undefined + } + onDrop={ + !props.preview && entity_set.permissions.write ? handleDrop : undefined + } style={{ minHeight: height + 512, contain: "size layout paint", -- 2.51.2 From edfd9972ceb73c51ce4acfb962ad9f07763dc80e Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 12:53:29 -0400 Subject: [PATCH 08/33] remove page margin on canvas --- components/Pages/Page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index cda9e3f1..0e1983ff 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -132,7 +132,7 @@ export const PageWrapper = (props: { `} > {props.children} -
    + {props.pageType === "doc" &&
    }
    {props.pageOptions} -- 2.51.2 From 68f76472101284f9d54486d08ee14af8507a7e30 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 15:48:48 -0400 Subject: [PATCH 09/33] link to pub from subscribed thing --- app/lish/Subscribe.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 48b75e96..001ff6f8 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -23,6 +23,7 @@ import { addFeed } from "./addFeed"; import { useSearchParams } from "next/navigation"; import LoginForm from "app/login/LoginForm"; import { RSSSmall } from "components/Icons/RSSSmall"; +import { SpeedyLink } from "components/SpeedyLink"; type State = | { state: "email" } @@ -217,7 +218,12 @@ export const SubscribeWithBluesky = (props: { pub_uri={props.pub_uri} setSuccessModalOpen={setSuccessModalOpen} /> - +
    @@ -246,7 +252,12 @@ const ManageSubscription = (props: { className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} >
    - You're Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} + You're Subscribed{props.isPost ? ` to ` : "!"} + {props.isPost && ( + + {props.pubName} + + )}
    Manage
    } @@ -266,7 +277,12 @@ const ManageSubscription = (props: { )} - + Get RSS -- 2.51.2 From 75d9d730610585d403a5d19a2335214696517b33 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 16:24:46 -0400 Subject: [PATCH 10/33] share scroll logic on pages and pubs --- src/utils/scrollIntoView.ts | 58 ++----------------------------------- 1 file changed, 3 insertions(+), 55 deletions(-) diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts index 6ce31089..9fae8be3 100644 --- a/src/utils/scrollIntoView.ts +++ b/src/utils/scrollIntoView.ts @@ -1,62 +1,10 @@ -// Generated with claude code, sonnet 4.5 -/** - * Scrolls an element into view within a scrolling container using Intersection Observer - * and the scrollTo API, instead of the native scrollIntoView. - * - * @param elementId - The ID of the element to scroll into view - * @param scrollContainerId - The ID of the scrolling container (defaults to "pages") - * @param threshold - Intersection observer threshold (0-1, defaults to 0.2 for 20%) - */ +import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; + export function scrollIntoView( elementId: string, scrollContainerId: string = "pages", threshold: number = 0.9, ) { const element = document.getElementById(elementId); - const scrollContainer = document.getElementById(scrollContainerId); - - if (!element || !scrollContainer) { - console.warn(`scrollIntoView: element or container not found`, { - elementId, - scrollContainerId, - element, - scrollContainer, - }); - return; - } - - // Create an intersection observer to check if element is visible - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - - // If element is not sufficiently visible, scroll to it - if (!entry.isIntersecting || entry.intersectionRatio < threshold) { - const elementRect = element.getBoundingClientRect(); - const containerRect = scrollContainer.getBoundingClientRect(); - - // Calculate the target scroll position - // We want to center the element horizontally in the container - const targetScrollLeft = - scrollContainer.scrollLeft + - elementRect.left - - containerRect.left - - (containerRect.width - elementRect.width) / 2; - - scrollContainer.scrollTo({ - left: targetScrollLeft, - behavior: "smooth", - }); - } - - // Disconnect after checking once - observer.disconnect(); - }, - { - root: scrollContainer, - threshold: threshold, - }, - ); - - observer.observe(element); + scrollIntoViewIfNeeded(element, false); } -- 2.51.2 From aa79870625d0fec109432aa44e4011583573938b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 16:53:44 -0400 Subject: [PATCH 11/33] smooth scroll into view in pubs --- src/utils/scrollIntoView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts index 9fae8be3..1b8ff6a1 100644 --- a/src/utils/scrollIntoView.ts +++ b/src/utils/scrollIntoView.ts @@ -6,5 +6,5 @@ export function scrollIntoView( threshold: number = 0.9, ) { const element = document.getElementById(elementId); - scrollIntoViewIfNeeded(element, false); + scrollIntoViewIfNeeded(element, false, "smooth"); } -- 2.51.2 From 6f515d5a9ad42e3ce1ec8d1a8112ff788d95ceaf Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 18:11:05 -0400 Subject: [PATCH 12/33] Feature/atp canvas blocks (#227) * add page block type to lexicon and basic component * add page block generated files * set up leaflet and post to use the same layout component and adjusted the interaction panel to work within that * unified page, page wrapper, and post layouts, made subpage look nice in post * added a bit of padding under the bottom of posts, deleted unused code * remove extra merge conflict * add published page block preview * always render interaction drawer * factor out useDrawerOpen * loosen base_path to string * scroll pages and comments into view properly * fixed a weird scrolling issue, also squished the interaction drawer and page together * make quotes have a page component * open sub-page if quoted * change scroll into view threshold * scroll into view comments panel * render quote content from subpages * implement subpage interaction drawers and buttons * give quote popup a z-index * fix actually posting comments on subpages * convert comment to json before returning * add interactions preview for subpages * count top level quotes and comments correctly * ensure layout doesn't break with canvases * prev fix broke doc pages, fixed that lol * specify post pages are docs * absolutely position comments on subpage preview * add canvas lexicon * extract out page component to lineardocumentpage * add canvas pages and previews * handle narrow widths for the canvas * hook rules hooks rule * remove small canvas size * added interactions and metadata to leaflet and posted canvases * some fixes * make quotehandler work on canvas --------- Co-authored-by: celine --- actions/publishToPublication.ts | 99 +++++++- .../[did]/[publication]/[rkey]/CanvasPage.tsx | 240 ++++++++++++++++++ .../[rkey]/Interactions/Interactions.tsx | 33 ++- .../[rkey]/LinearDocumentPage.tsx | 135 ++++++++++ .../[publication]/[rkey]/PostContent.tsx | 14 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 173 +++++-------- .../[rkey]/PublishedPageBlock.tsx | 109 +++++++- components/Blocks/BlockCommands.tsx | 1 - components/Canvas.tsx | 82 +++--- components/Input.tsx | 2 +- components/Pages/Page.tsx | 21 +- components/Pages/PublicationMetadata.tsx | 6 +- lexicons/api/index.ts | 5 + lexicons/api/lexicons.ts | 104 +++++++- lexicons/api/types/pub/leaflet/document.ts | 7 +- .../api/types/pub/leaflet/pages/canvas.ts | 112 ++++++++ lexicons/build.ts | 1 + lexicons/pub/leaflet/document.json | 3 +- lexicons/pub/leaflet/pages/canvas.json | 111 ++++++++ lexicons/src/document.ts | 6 +- lexicons/src/pages/Canvas.ts | 48 ++++ lexicons/src/pages/index.ts | 1 + 22 files changed, 1128 insertions(+), 185 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx create mode 100644 lexicons/api/types/pub/leaflet/pages/canvas.ts create mode 100644 lexicons/pub/leaflet/pages/canvas.json create mode 100644 lexicons/src/pages/Canvas.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 33fb9fff..16fb7ebc 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -12,6 +12,7 @@ import { PubLeafletBlocksUnorderedList, PubLeafletDocument, PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, PubLeafletRichtextFacet, PubLeafletBlocksWebsite, PubLeafletBlocksCode, @@ -95,11 +96,21 @@ export async function publishToPublication({ $type: "pub.leaflet.pages.linearDocument", blocks: firstPageBlocks, }, - ...pages.map((p) => ({ - $type: "pub.leaflet.pages.linearDocument", - id: p.id, - blocks: p.blocks, - })), + ...pages.map((p) => { + if (p.type === "canvas") { + return { + $type: "pub.leaflet.pages.canvas" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesCanvas.Block[], + }; + } else { + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + } + }), ], }; let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); @@ -139,8 +150,13 @@ async function processBlocksToPages( root_entity: string, ) { let scan = scanIndexLocal(facts); - let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = - []; + let pages: { + id: string; + blocks: + | PubLeafletPagesLinearDocument.Block[] + | PubLeafletPagesCanvas.Block[]; + type: "doc" | "canvas"; + }[] = []; let firstEntity = scan.eav(root_entity, "root/page")?.[0]; if (!firstEntity) throw new Error("No root page"); @@ -228,11 +244,24 @@ async function processBlocksToPages( if (b.type === "card") { let [page] = scan.eav(b.value, "block/card"); if (!page) return; - let blocks = getBlocksWithTypeLocal(facts, page.data.value); - pages.push({ - id: page.data.value, - blocks: await blocksToRecord(blocks), - }); + let [pageType] = scan.eav(page.data.value, "page/type"); + + if (pageType?.data.value === "canvas") { + let canvasBlocks = await canvasBlocksToRecord(page.data.value); + pages.push({ + id: page.data.value, + blocks: canvasBlocks, + type: "canvas", + }); + } else { + let blocks = getBlocksWithTypeLocal(facts, page.data.value); + pages.push({ + id: page.data.value, + blocks: await blocksToRecord(blocks), + type: "doc", + }); + } + let block: $Typed = { $type: "pub.leaflet.blocks.page", id: page.data.value, @@ -359,6 +388,52 @@ async function processBlocksToPages( } return; } + + async function canvasBlocksToRecord( + pageID: string, + ): Promise { + let canvasBlocks = scan.eav(pageID, "canvas/block"); + return ( + await Promise.all( + canvasBlocks.map(async (canvasBlock) => { + let blockEntity = canvasBlock.data.value; + let position = canvasBlock.data.position; + + // Get the block content + let blockType = scan.eav(blockEntity, "block/type")?.[0]; + if (!blockType) return null; + + let block: Block = { + type: blockType.data.value, + value: blockEntity, + parent: pageID, + position: "", + factID: canvasBlock.id, + }; + + let content = await blockToRecord(block); + if (!content) return null; + + // Get canvas-specific properties + let width = + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; + let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] + ?.data.value; + + let canvasBlockRecord: PubLeafletPagesCanvas.Block = { + $type: "pub.leaflet.pages.canvas#block", + block: content, + x: position.x, + y: position.y, + width, + ...(rotation !== undefined && { rotation }), + }; + + return canvasBlockRecord; + }), + ) + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); + } } function YJSFragmentToFacets( diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx new file mode 100644 index 00000000..f7f1903a --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -0,0 +1,240 @@ +"use client"; +import { + PubLeafletPagesCanvas, + PubLeafletPagesLinearDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { PostPageData } from "./getPostPageData"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { AppBskyFeedDefs } from "@atproto/api"; +import { PageWrapper } from "components/Pages/Page"; +import { Block } from "./PostContent"; +import { CanvasBackgroundPattern } from "components/Canvas"; +import { + getCommentCount, + getQuoteCount, + Interactions, +} from "./Interactions/Interactions"; +import { Separator } from "components/Layout"; +import { Popover } from "components/Popover"; +import { InfoSmall } from "components/Icons/InfoSmall"; +import { PostHeader } from "./PostHeader/PostHeader"; +import { useDrawerOpen } from "./Interactions/InteractionDrawer"; + +export function CanvasPage({ + document, + blocks, + did, + profile, + preferences, + pubRecord, + prerenderedCodeBlocks, + bskyPostData, + document_uri, + pageId, + pageOptions, + fullPageScroll, + pages, +}: { + document_uri: string; + document: PostPageData; + blocks: PubLeafletPagesCanvas.Block[]; + profile: ProfileViewDetailed; + pubRecord: PubLeafletPublication.Record; + did: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + preferences: { showComments?: boolean }; + pageId?: string; + pageOptions?: React.ReactNode; + fullPageScroll: boolean; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; +}) { + let hasPageBackground = !!pubRecord.theme?.showPageBackground; + let isSubpage = !!pageId; + let drawer = useDrawerOpen(document_uri); + + return ( + + + + + ); +} + +function CanvasContent({ + blocks, + did, + prerenderedCodeBlocks, + bskyPostData, + pageId, + pages, +}: { + blocks: PubLeafletPagesCanvas.Block[]; + did: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + pageId?: string; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; +}) { + let height = blocks.length > 0 ? Math.max(...blocks.map((b) => b.y), 0) : 0; + + return ( +
    +
    + + + {blocks + .sort((a, b) => { + if (a.y === b.y) { + return a.x - b.x; + } + return a.y - b.y; + }) + .map((canvasBlock, index) => { + return ( + + ); + })} +
    +
    + ); +} + +function CanvasBlock({ + canvasBlock, + did, + prerenderedCodeBlocks, + bskyPostData, + pageId, + pages, + index, +}: { + canvasBlock: PubLeafletPagesCanvas.Block; + did: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + pageId?: string; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; + index: number; +}) { + let { x, y, width, rotation } = canvasBlock; + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; + + // Wrap the block in a LinearDocument.Block structure for compatibility + let linearBlock: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: canvasBlock.block, + }; + + return ( +
    +
    + +
    +
    + ); +} + +const CanvasMetadata = (props: { + pageId: string | undefined; + isSubpage: boolean | undefined; + data: PostPageData; + profile: ProfileViewDetailed; + preferences: { showComments?: boolean }; + quotesCount: number | undefined; + commentsCount: number | undefined; +}) => { + return ( +
    + + {!props.isSubpage && ( + <> + + } + > + + + + )} +
    + ); +}; + +const CanvasBackground = () => { + return ( +
    + +
    + ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 91d30991..39e86998 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -5,10 +5,12 @@ import { flushSync } from "react-dom"; import type { Json } from "supabase/database.types"; import { create } from "zustand"; import type { Comment } from "./Comments"; -import { QuotePosition } from "../quotePosition"; +import { decodeQuotePosition, QuotePosition } from "../quotePosition"; import { useContext } from "react"; import { PostPageContext } from "../PostPageContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; +import { PostPageData } from "../getPostPageData"; +import { PubLeafletComment } from "lexicons/api"; export type InteractionState = { drawerOpen: undefined | boolean; @@ -149,3 +151,32 @@ export const Interactions = (props: {
    ); }; + +export function getCommentCount(document: PostPageData, pageId?: string) { + if (!document) return; + + if (pageId) + return document.document_mentions_in_bsky.filter((q) => + q.link.includes(pageId), + ).length; + else + return document.document_mentions_in_bsky.filter((q) => { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return null; + const quotePosition = decodeQuotePosition(quoteParam); + return !quotePosition?.pageId; + }).length; +} + +export function getQuoteCount(document: PostPageData, pageId?: string) { + if (!document) return; + if (pageId) + return document.comments_on_documents.filter( + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, + ).length; + else + return document.comments_on_documents.filter( + (c) => !(c.record as PubLeafletComment.Record)?.onPage, + ).length; +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx new file mode 100644 index 00000000..57e20582 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -0,0 +1,135 @@ +"use client"; +import { + PubLeafletComment, + PubLeafletDocument, + PubLeafletPagesLinearDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { PostPageData } from "./getPostPageData"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; +import { EditTiny } from "components/Icons/EditTiny"; +import { + getCommentCount, + getQuoteCount, + Interactions, +} from "./Interactions/Interactions"; +import { PostContent } from "./PostContent"; +import { PostHeader } from "./PostHeader/PostHeader"; +import { useIdentityData } from "components/IdentityProvider"; +import { AppBskyFeedDefs } from "@atproto/api"; +import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { PageWrapper } from "components/Pages/Page"; +import { decodeQuotePosition } from "./quotePosition"; + +export function LinearDocumentPage({ + document, + blocks, + did, + profile, + preferences, + pubRecord, + prerenderedCodeBlocks, + bskyPostData, + document_uri, + pageId, + pageOptions, + fullPageScroll, +}: { + document_uri: string; + document: PostPageData; + blocks: PubLeafletPagesLinearDocument.Block[]; + profile?: ProfileViewDetailed; + pubRecord: PubLeafletPublication.Record; + did: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + preferences: { showComments?: boolean }; + pageId?: string; + pageOptions?: React.ReactNode; + fullPageScroll: boolean; +}) { + let { identity } = useIdentityData(); + let drawer = useDrawerOpen(document_uri); + + if (!document || !document.documents_in_publications[0].publications) + return null; + + let hasPageBackground = !!pubRecord.theme?.showPageBackground; + let record = document.data as PubLeafletDocument.Record; + + const isSubpage = !!pageId; + + return ( + <> + + {!isSubpage && profile && ( + + )} + + + {!isSubpage && ( + <> +
    +
    + + )} + + + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index a19a5458..ca3cfa83 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -9,6 +9,7 @@ import { PubLeafletBlocksWebsite, PubLeafletDocument, PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, PubLeafletBlocksHorizontalRule, PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, @@ -46,7 +47,7 @@ export function PostContent({ className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; - pages: PubLeafletPagesLinearDocument.Main[]; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { return (
    ; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -136,6 +137,9 @@ let Block = ({ let id = b.block.id; let page = pages.find((p) => p.id === id); if (!page) return; + + const isCanvas = PubLeafletPagesCanvas.isMain(page); + return ( ); @@ -354,7 +360,7 @@ let Block = ({ function ListItem(props: { index: number[]; - pages: PubLeafletPagesLinearDocument.Main[]; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; item: PubLeafletBlocksUnorderedList.ListItem; did: string; className?: string; diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 292a44bf..5a017669 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -1,19 +1,12 @@ "use client"; import { - PubLeafletComment, PubLeafletDocument, PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, PubLeafletPublication, } from "lexicons/api"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { SubscribeWithBluesky } from "app/lish/Subscribe"; -import { EditTiny } from "components/Icons/EditTiny"; -import { Interactions } from "./Interactions/Interactions"; -import { PostContent } from "./PostContent"; -import { PostHeader } from "./PostHeader/PostHeader"; -import { useIdentityData } from "components/IdentityProvider"; import { AppBskyFeedDefs } from "@atproto/api"; import { create } from "zustand/react"; import { @@ -23,12 +16,13 @@ import { import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; import { PageOptionButton } from "components/Pages/PageOptions"; import { CloseTiny } from "components/Icons/CloseTiny"; -import { PageWrapper } from "components/Pages/Page"; import { Fragment, useEffect } from "react"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; import { useParams } from "next/navigation"; import { decodeQuotePosition } from "./quotePosition"; +import { LinearDocumentPage } from "./LinearDocumentPage"; +import { CanvasPage } from "./CanvasPage"; const usePostPageUIState = create(() => ({ pages: [] as string[], @@ -124,7 +118,6 @@ export function PostPages({ bskyPostData: AppBskyFeedDefs.PostView[]; preferences: { showComments?: boolean }; }) { - let { identity } = useIdentityData(); let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); let pages = useOpenPages(); @@ -132,75 +125,24 @@ export function PostPages({ return null; let hasPageBackground = !!pubRecord.theme?.showPageBackground; - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; let record = document.data as PubLeafletDocument.Record; + + let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; return ( <> {!fullPageScroll && } - - - - { - const url = new URL(q.link); - const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; - const quotePosition = decodeQuotePosition(quoteParam); - return !quotePosition?.pageId; - }).length - } - commentsCount={ - document.comments_on_documents.filter( - (c) => !(c.record as PubLeafletComment.Record)?.onPage, - ).length - } - /> -
    -
    - {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications - ?.identity_did ? ( - - Edit Post - - ) : ( - - )} -
    -
    + preferences={preferences} + pubRecord={pubRecord} + prerenderedCodeBlocks={prerenderedCodeBlocks} + bskyPostData={bskyPostData} + document_uri={document_uri} + /> {drawer && !drawer.pageId && ( { let page = record.pages.find( - (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, - ) as PubLeafletPagesLinearDocument.Main | undefined; + (page) => + ( + page as + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + ).id === p, + ) as + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + | undefined; if (!page) return null; + + const isCanvas = PubLeafletPagesCanvas.isMain(page); + return ( - {/*JARED TODO : drawerOpen here is checking whether the drawer is open on the first page, rather than if it's open on this page. Please rewire this when you add drawers per page!*/} - closePage(page?.id!)} - hasPageBackground={hasPageBackground} - /> - } - > - - - q.link.includes(page.id!), - ).length + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} + pageOptions={ + closePage(page?.id!)} + hasPageBackground={hasPageBackground} + /> } - commentsCount={ - document.comments_on_documents.filter( - (c) => - (c.record as PubLeafletComment.Record)?.onPage === - page.id, - ).length + /> + ) : ( + closePage(page?.id!)} + hasPageBackground={hasPageBackground} + /> } /> - + )} {drawer && drawer.pageId === page.id && ( ; bskyPostData: AppBskyFeedDefs.PostView[]; + isCanvas?: boolean; + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { //switch to use actually state let openPages = useOpenPages(); @@ -56,7 +60,20 @@ export function PublishedPageLinkBlock(props: { openPage(props.parentPageId, props.pageId); }} > - + {props.isCanvas ? ( + + ) : ( + + )}
    ); } @@ -203,8 +220,8 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { openInteractionDrawer("quotes", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); }} - aria-label="Page quotes" > + Page quotes {quotes}{" "} )} @@ -221,11 +238,93 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { openInteractionDrawer("comments", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); }} - aria-label="Page comments" > + Page comments {comments}{" "} )}
    ); }; + +const CanvasLinkBlock = (props: { + blocks: PubLeafletPagesCanvas.Block[]; + did: string; + pageId: string; + bskyPostData: AppBskyFeedDefs.PostView[]; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; +}) => { + let pageWidth = `var(--page-width-unitless)`; + let height = + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; + + return ( +
    +
    +
    +
    + +
    + {props.blocks + .sort((a, b) => { + if (a.y === b.y) { + return a.x - b.x; + } + return a.y - b.y; + }) + .map((canvasBlock, index) => { + let { x, y, width, rotation } = canvasBlock; + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; + + // Wrap the block in a LinearDocument.Block structure for compatibility + let linearBlock: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: canvasBlock.block, + }; + + return ( +
    +
    + +
    +
    + ); + })} +
    +
    +
    + ); +}; diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index de82808a..467eb02f 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -369,7 +369,6 @@ export const blockCommands: Command[] = [ name: "New Canvas", icon: , type: "page", - hiddenInPublication: true, onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "card"); diff --git a/components/Canvas.tsx b/components/Canvas.tsx index 9baf9da4..6f5b1af8 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -14,9 +14,24 @@ import { Media } from "./Media"; import { TooltipButton } from "./Buttons"; import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; import { AddSmall } from "./Icons/AddSmall"; +import { InfoSmall } from "./Icons/InfoSmall"; +import { Popover } from "./Popover"; +import { Separator } from "./Layout"; +import { CommentTiny } from "./Icons/CommentTiny"; +import { QuoteTiny } from "./Icons/QuoteTiny"; +import { PublicationMetadata } from "./Pages/PublicationMetadata"; +import { useLeafletPublicationData } from "./PageSWRDataProvider"; +import { + PubLeafletPublication, + PubLeafletPublicationRecord, +} from "lexicons/api"; import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; -export function Canvas(props: { entityID: string; preview?: boolean }) { +export function Canvas(props: { + entityID: string; + preview?: boolean; + first?: boolean; +}) { let entity_set = useEntitySetContext(); let ref = useRef(null); useEffect(() => { @@ -45,9 +60,6 @@ export function Canvas(props: { entityID: string; preview?: boolean }) { return () => abort.abort(); }); - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data - .value; - return (
    + + + -
    ); } @@ -150,32 +164,40 @@ export function CanvasContent(props: { entityID: string; preview?: boolean }) { ); } -function CanvasWidthHandle(props: { entityID: string }) { - let canvasFocused = useUIState((s) => s.focusedEntity?.entityType === "page"); - let { rep } = useReplicache(); - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data - .value; +const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { + let { data: pub } = useLeafletPublicationData(); + if (!pub || !pub.publications) return null; + + let pubRecord = pub.publications.record as PubLeafletPublication.Record; + let showComments = pubRecord.preferences?.showComments; + return ( - +
    + )} + + ) : ( + <> + {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