diff --git a/components/Popover/index.tsx b/components/Popover/index.tsx index 6ad13603..fb987d3d 100644 --- a/components/Popover/index.tsx +++ b/components/Popover/index.tsx @@ -43,7 +43,7 @@ export const Popover = (props: { Date: Thu, 22 Jan 2026 13:09:25 -0500 Subject: [PATCH 002/106] fix string quoting --- src/utils/uriHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/uriHelpers.ts b/src/utils/uriHelpers.ts index acc08005..8931117c 100644 --- a/src/utils/uriHelpers.ts +++ b/src/utils/uriHelpers.ts @@ -44,5 +44,5 @@ export function publicationNameOrUriFilter( ).toString(); legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); } - return `name.eq."${nameOrRkey}"",uri.eq."${standard}"",uri.eq."${legacy}"`; + return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`; } -- 2.51.2 From 7affa516f18f1bc44fb3974bb4694d19f2d99b4c Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 22 Jan 2026 13:28:55 -0500 Subject: [PATCH 003/106] use main domain for profile url in profile header --- app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index 044e01fc..be7d44da 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -16,7 +16,7 @@ export const ProfileHeader = (props: { popover?: boolean; }) => { let profileRecord = props.profile; - const profileUrl = `/p/${props.profile.handle}`; + const profileUrl = `https://leaflet.pub/p/${props.profile.handle}`; const avatarElement = ( Date: Thu, 22 Jan 2026 13:39:11 -0500 Subject: [PATCH 004/106] add notification for bluesky posts mentioned in docs --- actions/publishToPublication.ts | 164 ++++++++++++------ .../BskyPostEmbedNotification.tsx | 44 +++++ .../notifications/NotificationList.tsx | 4 + src/notifications.ts | 108 +++++++++++- 4 files changed, 265 insertions(+), 55 deletions(-) create mode 100644 app/(home-pages)/notifications/BskyPostEmbedNotification.tsx diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 0e65d822..bad2144c 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -903,6 +903,7 @@ async function createMentionNotifications( const mentionedDids = new Set(); const mentionedPublications = new Map(); // Map of DID -> publication URI const mentionedDocuments = new Map(); // Map of DID -> document URI + const embeddedBskyPosts = new Map(); // Map of author DID -> post URI // Extract pages from either format let pages: PubLeafletContent.Main["pages"] | undefined; @@ -917,60 +918,89 @@ async function createMentionNotifications( if (!pages) return; - // Extract mentions from all text blocks in all pages - for (const page of pages) { - if (page.$type === "pub.leaflet.pages.linearDocument") { - const linearPage = page as PubLeafletPagesLinearDocument.Main; - for (const blockWrapper of linearPage.blocks) { - const block = blockWrapper.block; - if (block.$type === "pub.leaflet.blocks.text") { - const textBlock = block as PubLeafletBlocksText.Main; - if (textBlock.facets) { - for (const facet of textBlock.facets) { - for (const feature of facet.features) { - // Check for DID mentions - if (PubLeafletRichtextFacet.isDidMention(feature)) { - if (feature.did !== authorDid) { - mentionedDids.add(feature.did); - } + // Helper to extract blocks from all pages (both linear and canvas) + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { + const blocks: ( + | PubLeafletPagesLinearDocument.Block["block"] + | PubLeafletPagesCanvas.Block["block"] + )[] = []; + for (const page of pages) { + if (page.$type === "pub.leaflet.pages.linearDocument") { + const linearPage = page as PubLeafletPagesLinearDocument.Main; + for (const blockWrapper of linearPage.blocks) { + blocks.push(blockWrapper.block); + } + } else if (page.$type === "pub.leaflet.pages.canvas") { + const canvasPage = page as PubLeafletPagesCanvas.Main; + for (const blockWrapper of canvasPage.blocks) { + blocks.push(blockWrapper.block); + } + } + } + return blocks; + } + + const allBlocks = getAllBlocks(pages); + + // Extract mentions from all text blocks and embedded Bluesky posts + for (const block of allBlocks) { + // Check for embedded Bluesky posts + if (PubLeafletBlocksBskyPost.isMain(block)) { + const bskyPostUri = block.postRef.uri; + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) + const postAuthorDid = new AtUri(bskyPostUri).host; + if (postAuthorDid !== authorDid) { + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); + } + } + + // Check for text blocks with mentions + if (block.$type === "pub.leaflet.blocks.text") { + const textBlock = block as PubLeafletBlocksText.Main; + if (textBlock.facets) { + for (const facet of textBlock.facets) { + for (const feature of facet.features) { + // Check for DID mentions + if (PubLeafletRichtextFacet.isDidMention(feature)) { + if (feature.did !== authorDid) { + mentionedDids.add(feature.did); + } + } + // Check for AT URI mentions (publications and documents) + if (PubLeafletRichtextFacet.isAtMention(feature)) { + const uri = new AtUri(feature.atURI); + + if (isPublicationCollection(uri.collection)) { + // Get the publication owner's DID + const { data: publication } = await supabaseServerClient + .from("publications") + .select("identity_did") + .eq("uri", feature.atURI) + .single(); + + if (publication && publication.identity_did !== authorDid) { + mentionedPublications.set( + publication.identity_did, + feature.atURI, + ); } - // Check for AT URI mentions (publications and documents) - if (PubLeafletRichtextFacet.isAtMention(feature)) { - const uri = new AtUri(feature.atURI); - - if (isPublicationCollection(uri.collection)) { - // Get the publication owner's DID - const { data: publication } = await supabaseServerClient - .from("publications") - .select("identity_did") - .eq("uri", feature.atURI) - .single(); - - if (publication && publication.identity_did !== authorDid) { - mentionedPublications.set( - publication.identity_did, - feature.atURI, - ); - } - } else if (isDocumentCollection(uri.collection)) { - // Get the document owner's DID - const { data: document } = await supabaseServerClient - .from("documents") - .select("uri, data") - .eq("uri", feature.atURI) - .single(); - - if (document) { - const normalizedMentionedDoc = normalizeDocumentRecord( - document.data, - ); - // Get the author from the document URI (the DID is the host part) - const mentionedUri = new AtUri(feature.atURI); - const docAuthor = mentionedUri.host; - if (normalizedMentionedDoc && docAuthor !== authorDid) { - mentionedDocuments.set(docAuthor, feature.atURI); - } - } + } else if (isDocumentCollection(uri.collection)) { + // Get the document owner's DID + const { data: document } = await supabaseServerClient + .from("documents") + .select("uri, data") + .eq("uri", feature.atURI) + .single(); + + if (document) { + const normalizedMentionedDoc = normalizeDocumentRecord( + document.data, + ); + // Get the author from the document URI (the DID is the host part) + const mentionedUri = new AtUri(feature.atURI); + const docAuthor = mentionedUri.host; + if (normalizedMentionedDoc && docAuthor !== authorDid) { + mentionedDocuments.set(docAuthor, feature.atURI); } } } @@ -1027,4 +1057,32 @@ async function createMentionNotifications( await supabaseServerClient.from("notifications").insert(notification); await pingIdentityToUpdateNotification(recipientDid); } + + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) + if (embeddedBskyPosts.size > 0) { + // Check which of the Bluesky post authors have Leaflet accounts + const { data: identities } = await supabaseServerClient + .from("identities") + .select("atp_did") + .in("atp_did", Array.from(embeddedBskyPosts.keys())); + + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); + + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { + // Only notify if the post author has a Leaflet account + if (leafletUserDids.has(postAuthorDid)) { + const notification: Notification = { + id: v7(), + recipient: postAuthorDid, + data: { + type: "bsky_post_embed", + document_uri: documentUri, + bsky_post_uri: bskyPostUri, + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(postAuthorDid); + } + } + } } diff --git a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx new file mode 100644 index 00000000..2b433771 --- /dev/null +++ b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx @@ -0,0 +1,44 @@ +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { ContentLayout, Notification } from "./Notification"; +import { HydratedBskyPostEmbedNotification } from "src/notifications"; +import { AtUri } from "@atproto/api"; + +export const BskyPostEmbedNotification = ( + props: HydratedBskyPostEmbedNotification, +) => { + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + + const docUri = new AtUri(props.document.uri); + const rkey = docUri.rkey; + const did = docUri.host; + + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; + + const embedder = props.documentCreatorHandle + ? `@${props.documentCreatorHandle}` + : "Someone"; + + return ( + } + actionText={<>{embedder} embedded your Bluesky post} + content={ + + {props.bskyPostText && ( +
+              {props.bskyPostText}
+            
+ )} +
+ } + /> + ); +}; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx index 96d931eb..a0ec12ee 100644 --- a/app/(home-pages)/notifications/NotificationList.tsx +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -8,6 +8,7 @@ import { ReplyNotification } from "./ReplyNotification"; import { useIdentityData } from "components/IdentityProvider"; import { FollowNotification } from "./FollowNotification"; import { QuoteNotification } from "./QuoteNotification"; +import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; import { MentionNotification } from "./MentionNotification"; import { CommentMentionNotification } from "./CommentMentionNotification"; @@ -48,6 +49,9 @@ export function NotificationList({ if (n.type === "quote") { return ; } + if (n.type === "bsky_post_embed") { + return ; + } if (n.type === "mention") { return ; } diff --git a/src/notifications.ts b/src/notifications.ts index 5e0711fd..f4353d2b 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -21,6 +21,7 @@ export type NotificationData = | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string } | { type: "quote"; bsky_post_uri: string; document_uri: string } + | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } | { type: "mention"; document_uri: string; mention_type: "did" } | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } @@ -32,22 +33,24 @@ export type HydratedNotification = | HydratedCommentNotification | HydratedSubscribeNotification | HydratedQuoteNotification + | HydratedBskyPostEmbedNotification | HydratedMentionNotification | HydratedCommentMentionNotification; export async function hydrateNotifications( notifications: NotificationRow[], ): Promise> { // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ hydrateCommentNotifications(notifications), hydrateSubscribeNotifications(notifications), hydrateQuoteNotifications(notifications), + hydrateBskyPostEmbedNotifications(notifications), hydrateMentionNotifications(notifications), hydrateCommentMentionNotifications(notifications), ]); // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; // Sort by created_at to maintain order allHydrated.sort( @@ -207,6 +210,107 @@ async function hydrateQuoteNotifications(notifications: NotificationRow[]) { .filter((n) => n !== null); } +export type HydratedBskyPostEmbedNotification = Awaited< + ReturnType +>[0]; + +async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { + const bskyPostEmbedNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => + (n.data as NotificationData)?.type === "bsky_post_embed", + ); + + if (bskyPostEmbedNotifications.length === 0) { + return []; + } + + // Fetch document data (the leaflet that embedded the post) + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); + + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ + supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", documentUris), + supabaseServerClient + .from("bsky_posts") + .select("*") + .in("uri", bskyPostUris), + ]); + + // Find which posts we need to fetch from the API + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); + + // Fetch missing posts from Bluesky API + const fetchedPosts = new Map(); + if (missingPostUris.length > 0) { + try { + const { AtpAgent } = await import("@atproto/api"); + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); + for (const post of response.data.posts) { + const record = post.record as { text?: string }; + fetchedPosts.set(post.uri, { text: record.text ?? "" }); + } + } catch (error) { + console.error("Failed to fetch Bluesky posts:", error); + } + } + + // Extract unique DIDs from document URIs to resolve handles + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; + + // Resolve DIDs to handles in parallel + const didToHandleMap = new Map(); + await Promise.all( + documentCreatorDids.map(async (did) => { + try { + const resolved = await idResolver.did.resolve(did); + const handle = resolved?.alsoKnownAs?.[0] + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix + : null; + didToHandleMap.set(did, handle); + } catch (error) { + console.error(`Failed to resolve DID ${did}:`, error); + didToHandleMap.set(did, null); + } + }), + ); + + return bskyPostEmbedNotifications + .map((notification) => { + const document = documents?.find((d) => d.uri === notification.data.document_uri); + if (!document) return null; + + const documentCreatorDid = new AtUri(notification.data.document_uri).host; + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; + + // Get post text from cache or fetched data + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; + + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "bsky_post_embed" as const, + document_uri: notification.data.document_uri, + bsky_post_uri: notification.data.bsky_post_uri, + document, + documentCreatorHandle, + bskyPostText, + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), + }; + }) + .filter((n) => n !== null); +} + export type HydratedMentionNotification = Awaited< ReturnType >[0]; -- 2.51.2 From 5fe581d52891e0c1df8972978a8e4dfbd7e5b914 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 22 Jan 2026 09:23:49 -1000 Subject: [PATCH 005/106] Update/delete blocks (#260) * initial delete button but no wiring yet * added move block up and down buttons to the block options buttons, refactored moveBlockUp/Down to be utility functions * handling delete in the block options * adjusting full bleed styling for image blocks * change blockToolbar to imageToolbar, not * don't show the toolbar on blocks without toolbar actions * minor fixes to button block, implement a full wdith mode * Lock is GONE YO * pretty up the cover image button * group multiselect delete into one undo group * fix issue with empty codeBlocks lookin funny * remove long press to select on mobile docs, kept for canvas * fixed button block layout weirdness, added an ative state to text alignment toolbar * fixing min-h isses in codeblock * make things blocks easier to manuever in canvas * added mouse handlers back to canvas * small block bugs * fixed pageLinkBlock not showing proper non-text-block options * fixed some undo redo bugs --- .claude/settings.local.json | 9 + .github/pull_request_template.md | 1 - app/[leaflet_id]/Footer.tsx | 18 +- app/globals.css | 2 + app/lish/[did]/[publication]/icon/route.ts | 1 - components/Blocks/Block.tsx | 208 ++++++++++------ .../Blocks/BlueskyPostBlock/BlueskyEmpty.tsx | 4 +- components/Blocks/ButtonBlock.tsx | 53 +++-- components/Blocks/CodeBlock.tsx | 133 +++++------ components/Blocks/DateTimeBlock.tsx | 5 +- components/Blocks/EmbedBlock.tsx | 8 +- components/Blocks/ExternalLinkBlock.tsx | 4 +- components/Blocks/ImageBlock.tsx | 77 +++--- components/Blocks/MailboxBlock.tsx | 21 +- components/Blocks/PageLinkBlock.tsx | 10 +- components/Blocks/PollBlock/index.tsx | 16 +- components/Blocks/PublicationPollBlock.tsx | 11 +- components/Blocks/RSVPBlock/index.tsx | 9 +- components/Blocks/TextBlock/index.tsx | 10 +- components/Blocks/index.tsx | 4 +- components/Blocks/useBlockKeyboardHandlers.ts | 20 +- components/Blocks/useBlockMouseHandlers.ts | 24 +- components/Canvas.tsx | 81 ++++--- components/DesktopFooter.tsx | 7 + components/Icons/DeleteTiny.tsx | 19 ++ components/Icons/ImageCoverImage.tsx | 20 ++ components/Pages/PageOptions.tsx | 2 +- components/SelectionManager/index.tsx | 72 +----- components/Toolbar/BlockToolbar.1.tsx | 0 components/Toolbar/BlockToolbar.tsx | 223 ------------------ components/Toolbar/ImageToolbar.tsx | 44 +++- components/Toolbar/LockBlockButton.tsx | 103 -------- components/Toolbar/MultiSelectToolbar.tsx | 45 +++- components/Toolbar/TextAlignmentToolbar.tsx | 8 + components/Toolbar/TextToolbar.tsx | 3 - components/Toolbar/index.tsx | 144 +++++------ src/hooks/useLongPress.ts | 2 +- src/replicache/clientMutationContext.ts | 7 + src/replicache/mutations.ts | 41 +++- src/utils/deleteBlock.ts | 12 +- src/utils/focusBlock.ts | 4 +- src/utils/moveBlock.ts | 81 +++++++ 42 files changed, 768 insertions(+), 798 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 components/Icons/DeleteTiny.tsx delete mode 100644 components/Toolbar/BlockToolbar.1.tsx delete mode 100644 components/Toolbar/BlockToolbar.tsx delete mode 100644 components/Toolbar/LockBlockButton.tsx create mode 100644 src/utils/moveBlock.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..8f66d8d3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__acp__Edit", + "mcp__acp__Write", + "mcp__acp__Bash" + ] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 127a7e34..441bf8f4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,5 +2,4 @@ Before you pull, have you made sure... - it looks good on both mobile and desktop - it undo's like it ought to - it handles keyboard interactions reasonably well -- it behaves as you would expect if you lock it - no build errors!!! diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx index 94a5a9a3..d51cff0d 100644 --- a/app/[leaflet_id]/Footer.tsx +++ b/app/[leaflet_id]/Footer.tsx @@ -8,22 +8,37 @@ import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; import { PublishButton } from "./actions/PublishButton"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; import { Watermark } from "components/Watermark"; import { BackToPubButton } from "./actions/BackToPubButton"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { useIdentityData } from "components/IdentityProvider"; +import { useEntity } from "src/replicache"; +import { block } from "sharp"; +export function hasBlockToolbar(blockType: string | null | undefined) { + return ( + blockType === "text" || + blockType === "heading" || + blockType === "blockquote" || + blockType === "button" || + blockType === "datetime" || + blockType === "image" + ); +} export function LeafletFooter(props: { entityID: string }) { let focusedBlock = useUIState((s) => s.focusedEntity); + let entity_set = useEntitySetContext(); let { identity } = useIdentityData(); let { data: pub } = useLeafletPublicationData(); + let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data + .value; return ( {focusedBlock && focusedBlock.entityType == "block" && + hasBlockToolbar(blockType) && entity_set.permissions.write ? (
) : entity_set.permissions.write ? ( diff --git a/app/globals.css b/app/globals.css index 6a447a52..73d26f5b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -274,6 +274,8 @@ pre.shiki { @apply p-2; @apply rounded-md; @apply overflow-auto; + @apply sm:min-h-12; + @apply min-h-10; @media (min-width: 640px) { @apply p-3; diff --git a/app/lish/[did]/[publication]/icon/route.ts b/app/lish/[did]/[publication]/icon/route.ts index ebfdbe82..2a38ab79 100644 --- a/app/lish/[did]/[publication]/icon/route.ts +++ b/app/lish/[did]/[publication]/icon/route.ts @@ -14,7 +14,6 @@ export async function GET( request: NextRequest, props: { params: Promise<{ did: string; publication: string }> }, ) { - console.log("are we getting here?"); const params = await props.params; try { let did = decodeURIComponent(params.did); diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 64efceec..dbf1e61c 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -33,6 +33,11 @@ import { HorizontalRule } from "./HorizontalRule"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; import { focusPage } from "src/utils/focusPage"; +import { DeleteTiny } from "components/Icons/DeleteTiny"; +import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; +import { Separator } from "components/Layout"; +import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; +import { deleteBlock } from "src/utils/deleteBlock"; export type Block = { factID: string; @@ -63,7 +68,6 @@ export const Block = memo(function Block( // Block handles all block level events like // mouse events, keyboard events and longPress, and setting AreYouSure state // and shared styling like padding and flex for list layouting - let { rep } = useReplicache(); let mouseHandlers = useBlockMouseHandlers(props); let handleDrop = useHandleDrop({ parent: props.parent, @@ -72,7 +76,7 @@ export const Block = memo(function Block( }); let entity_set = useEntitySetContext(); - let { isLongPress, handlers } = useLongPress(() => { + let { isLongPress, longPressHandlers } = useLongPress(() => { if (isTextBlock[props.type]) return; if (isLongPress.current) { focusBlock( @@ -85,6 +89,20 @@ export const Block = memo(function Block( let selected = useUIState( (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ); + let alignment = useEntity(props.value, "block/text-alignment")?.data.value; + + let alignmentStyle = + props.type === "button" || props.type === "image" + ? "justify-center" + : "justify-start"; + + if (alignment) + alignmentStyle = { + left: "justify-start", + right: "justify-end", + center: "justify-center", + justify: "justify-start", + }[alignment]; let [areYouSure, setAreYouSure] = useState(false); useEffect(() => { @@ -98,7 +116,7 @@ export const Block = memo(function Block( return (
{ // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers let BlockTypeComponent = BlockTypeComponents[props.type]; - let alignment = useEntity(props.value, "block/text-alignment")?.data.value; - - let alignmentStyle = - props.type === "button" || props.type === "image" - ? "justify-center" - : "justify-start"; - - if (alignment) - alignmentStyle = { - left: "justify-start", - right: "justify-end", - center: "justify-center", - justify: "justify-start", - }[alignment]; if (!BlockTypeComponent) return
unknown block
; return ( -
+ <> {props.listData && } {props.areYouSure ? ( )} -
+ ); }; @@ -326,11 +330,6 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.length > 1, ); - let isSelected = useUIState((s) => - s.selectedBlocks.find((b) => b.value === props.entityID), - ); - let isLocked = useEntity(props.value, "block/is-locked"); - let nextBlockSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), ); @@ -338,10 +337,7 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), ); - if (isMultiselected || (isLocked?.data.value && isSelected)) - // not sure what multiselected and selected classes are doing (?) - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected - + if (isMultiselected) return ( <>
{ ${!prevBlockSelected && "rounded-t-md"} ${!nextBlockSelected && "rounded-b-md"} `} - style={ - isLocked?.data.value - ? { - maskImage: "var(--hatchSVG)", - maskRepeat: "repeat repeat", - } - : {} - } - >
- {isLocked?.data.value && ( -
- -
- )} + /> ); }; export const BlockLayout = (props: { - isSelected?: boolean; + isSelected: boolean; children: React.ReactNode; className?: string; + optionsClassName?: string; hasBackground?: "accent" | "page"; borderOnHover?: boolean; + hasAlignment?: boolean; + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; }) => { + // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete return (
+
+ {props.children} +
+ {props.isSelected && ( + + )} +
+ ); +}; + +let debounced: null | number = null; + +const NonTextBlockOptions = (props: { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + optionsClassName?: string; +}) => { + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + let focusedEntity = useUIState((s) => s.focusedEntity); + let focusedEntityType = useEntity( + focusedEntity?.entityType === "page" + ? focusedEntity.entityID + : focusedEntity?.parent || null, + "page/type", + ); + + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); + if (focusedEntity?.entityType === "page") return; + + if (isMultiselected) return; + + return ( +
- {props.children} + {focusedEntityType?.data.value !== "canvas" && ( + <> + + + + + )} +
); }; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx index 33a427ac..2e4703a9 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx @@ -18,7 +18,6 @@ export const BlueskyPostEmpty = (props: BlockProps) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; let entity_set = useEntitySetContext(); let [urlValue, setUrlValue] = useState(""); @@ -91,7 +90,6 @@ export const BlueskyPostEmpty = (props: BlockProps) => { className="w-full grow border-none outline-hidden bg-transparent " placeholder="bsky.app/post-url" value={urlValue} - disabled={isLocked} onChange={(e) => setUrlValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { @@ -109,7 +107,7 @@ export const BlueskyPostEmpty = (props: BlockProps) => {
+ {isLocalUpload || image.data.local ? ( )} {altText !== undefined && !props.preview ? ( @@ -204,32 +207,38 @@ const CoverImageButton = (props: { entityID: string }) => { ); // Only show if focused, in a publication, has write permissions, and no cover image is set - if ( - !isFocused || - !pubData?.publications || - !entity_set.permissions.write || - coverImage - ) + if (!isFocused || !pubData?.publications || !entity_set.permissions.write) return null; - - return ( -
- -
+ Remove Cover Image + + + ); + return ( + { + e.preventDefault(); + e.stopPropagation(); + await rep?.mutate.updatePublicationDraft({ + cover_image: props.entityID, + }); + }} + > + Use as Cover Image + + ); }; diff --git a/components/Blocks/MailboxBlock.tsx b/components/Blocks/MailboxBlock.tsx index 1bc591cd..670affae 100644 --- a/components/Blocks/MailboxBlock.tsx +++ b/components/Blocks/MailboxBlock.tsx @@ -26,7 +26,12 @@ import { usePageTitle } from "components/utils/UpdateLeafletTitle"; import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; import { InfoSmall } from "components/Icons/InfoSmall"; -export const MailboxBlock = (props: BlockProps) => { +export const MailboxBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let isSubscribed = useSubscriptionStatus(props.entityID); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), @@ -41,7 +46,10 @@ export const MailboxBlock = (props: BlockProps) => { let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); if (!permission) return ( - + ); return ( @@ -49,6 +57,8 @@ export const MailboxBlock = (props: BlockProps) => { { ); }; -const MailboxReaderView = (props: { entityID: string; parent: string }) => { +const MailboxReaderView = (props: { + entityID: string; + parent: string; + +}) => { let isSubscribed = useSubscriptionStatus(props.entityID); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), @@ -133,6 +147,7 @@ const MailboxReaderView = (props: { entityID: string; parent: string }) => { {!isSubscribed?.confirmed ? ( diff --git a/components/Blocks/PageLinkBlock.tsx b/components/Blocks/PageLinkBlock.tsx index df27bd24..90474ea4 100644 --- a/components/Blocks/PageLinkBlock.tsx +++ b/components/Blocks/PageLinkBlock.tsx @@ -13,7 +13,13 @@ import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; -export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { +export function PageLinkBlock( + props: BlockProps & { + preview?: boolean; + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) { let page = useEntity(props.entityID, "block/card"); let type = useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; @@ -32,6 +38,8 @@ export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { { +export const PollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let { data: pub } = useLeafletPublicationData(); if (!pub) return ; return ; }; -export const LeafletPollBlock = (props: BlockProps) => { +export const LeafletPollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -64,6 +74,8 @@ export const LeafletPollBlock = (props: BlockProps) => { {pollState === "editing" ? ( diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx index f776b566..e7fcf940 100644 --- a/components/Blocks/PublicationPollBlock.tsx +++ b/components/Blocks/PublicationPollBlock.tsx @@ -21,8 +21,13 @@ import { ids } from "lexicons/api/lexicons"; * 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, normalizedDocument } = useLeafletPublicationData(); +export const PublicationPollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { + let { data: publicationData } = useLeafletPublicationData(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -57,6 +62,8 @@ export const PublicationPollBlock = (props: BlockProps) => { className="poll flex flex-col gap-2" hasBackground={"accent"} isSelected={!!isSelected} + areYouSure={props.areYouSure} + setAreYouSure={props.setAreYouSure} > void; + }, +) { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -32,6 +37,8 @@ export function RSVPBlock(props: BlockProps) { diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index f774469b..3bfd54c0 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -41,17 +41,13 @@ export function TextBlock( preview?: boolean; }, ) { - let isLocked = useEntity(props.entityID, "block/is-locked"); let initialized = useHasPageLoaded(); let first = props.previousBlock === null; let permission = useEntitySetContext().permissions.write; return ( <> - {(!initialized || - !permission || - props.preview || - isLocked?.data.value) && ( + {(!initialized || !permission || props.preview) && ( )} - {permission && !props.preview && !isLocked?.data.value && ( + {permission && !props.preview && (
@@ -330,7 +326,6 @@ const BlockifyLink = (props: { let { editorState } = props; let rep = useReplicache(); let smoker = useSmoker(); - let isLocked = useEntity(props.entityID, "block/is-locked"); let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); let isBlueskyPost = @@ -340,7 +335,6 @@ const BlockifyLink = (props: { // if its bluesky, change text to embed post if ( - !isLocked && focused && editorState && betterIsUrl(editorState.doc.textContent) && diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 6a7414b6..f5d49802 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -181,11 +181,9 @@ function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { : null, ); - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); if (!entity_set.permissions.write) return null; if ( - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || - props.lastBlock?.type === "heading") && + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && (!editorState?.editor || editorState.editor.doc.content.size <= 2) ) return null; diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts index 18b1f43b..6ffb977e 100644 --- a/components/Blocks/useBlockKeyboardHandlers.ts +++ b/components/Blocks/useBlockKeyboardHandlers.ts @@ -23,7 +23,6 @@ export function useBlockKeyboardHandlers( ) { let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; let isSelected = useUIState((s) => { let selectedBlocks = s.selectedBlocks; @@ -70,18 +69,16 @@ export function useBlockKeyboardHandlers( entity_set, areYouSure, setAreYouSure, - isLocked, }); undoManager.endGroup(); }; window.addEventListener("keydown", listener); return () => window.removeEventListener("keydown", listener); - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); } type Args = { e: KeyboardEvent; - isLocked: boolean; props: BlockProps; rep: Replicache; entity_set: { set: string }; @@ -133,16 +130,8 @@ function ArrowUp({ e, props }: Args) { } let debounced: null | number = null; -async function Backspace({ - e, - props, - rep, - areYouSure, - setAreYouSure, - isLocked, -}: Args) { +async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { // if this is a textBlock, let the textBlock/keymap handle the backspace - if (isLocked) return; // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) let el = e.target as HTMLElement; if ( @@ -154,11 +143,12 @@ async function Backspace({ if ((el as HTMLInputElement).value !== "") return; } - // if the block is a card or mailbox... + // if the block is a card, mailbox, rsvp, or poll... if ( props.type === "card" || props.type === "mailbox" || - props.type === "rsvp" + props.type === "rsvp" || + props.type === "poll" ) { // ...and areYouSure state is false, set it to true if (!areYouSure) { diff --git a/components/Blocks/useBlockMouseHandlers.ts b/components/Blocks/useBlockMouseHandlers.ts index eca3cc68..ddface11 100644 --- a/components/Blocks/useBlockMouseHandlers.ts +++ b/components/Blocks/useBlockMouseHandlers.ts @@ -1,5 +1,5 @@ import { useSelectingMouse } from "components/SelectionManager/selectionState"; -import { MouseEvent, useCallback, useRef } from "react"; +import { MouseEvent, useCallback } from "react"; import { useUIState } from "src/useUIState"; import { Block } from "./Block"; import { isTextBlock } from "src/utils/isTextBlock"; @@ -12,6 +12,24 @@ import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; import { elementId } from "src/utils/elementId"; let debounce: number | null = null; + +// Track scrolling state for mobile +let isScrolling = false; +let scrollTimeout: number | null = null; + +if (typeof window !== "undefined") { + window.addEventListener( + "scroll", + () => { + isScrolling = true; + if (scrollTimeout) window.clearTimeout(scrollTimeout); + scrollTimeout = window.setTimeout(() => { + isScrolling = false; + }, 150); + }, + true, + ); +} export function useBlockMouseHandlers(props: Block) { let entity_set = useEntitySetContext(); let isMobile = useIsMobile(); @@ -22,7 +40,7 @@ export function useBlockMouseHandlers(props: Block) { if ((e.target as Element).tagName === "BUTTON") return; if ((e.target as Element).tagName === "SELECT") return; if ((e.target as Element).tagName === "OPTION") return; - if (isMobile) return; + if (isMobile && isScrolling) return; if (!entity_set.permissions.write) return; useSelectingMouse.setState({ start: props.value }); if (e.shiftKey) { @@ -57,7 +75,7 @@ export function useBlockMouseHandlers(props: Block) { ); let onMouseEnter = useCallback( async (e: MouseEvent) => { - if (isMobile) return; + if (isMobile && isScrolling) return; if (!entity_set.permissions.write) return; if (debounce) window.clearTimeout(debounce); debounce = window.setTimeout(async () => { diff --git a/components/Canvas.tsx b/components/Canvas.tsx index dffb36b1..0faa3302 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -22,6 +22,7 @@ import { QuoteTiny } from "./Icons/QuoteTiny"; import { PublicationMetadata } from "./Pages/PublicationMetadata"; import { useLeafletPublicationData } from "./PageSWRDataProvider"; import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; +import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; export function Canvas(props: { entityID: string; @@ -286,9 +287,8 @@ function CanvasBlock(props: { }, [props, rep, permissions], ); - let { dragDelta, handlers } = useDrag({ + let { dragDelta, handlers: dragHandlers } = useDrag({ onDragEnd, - delay: isMobile, }); let widthOnDragEnd = useCallback( @@ -335,18 +335,20 @@ function CanvasBlock(props: { ); let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); - let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { - if (isLongPress.current && permissions.write) { - focusBlock( - { - type: type?.data.value || "text", - value: props.entityID, - parent: props.parent, - }, - { type: "start" }, - ); - } - }); + let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( + () => { + if (isLongPress.current && permissions.write) { + focusBlock( + { + type: type?.data.value || "text", + value: props.entityID, + parent: props.parent, + }, + { type: "start" }, + ); + } + }, + ); let angle = 0; if (rotateHandle.dragDelta) { let originX = rect.x + rect.width / 2; @@ -383,6 +385,8 @@ function CanvasBlock(props: { }; }, [props, type?.data.value]); useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); + let mouseHandlers = useBlockMouseHandlers(blockProps); + let isList = useEntity(props.entityID, "block/is-list"); let isFocused = useUIState( (s) => s.focusedEntity?.entityID === props.entityID, @@ -391,10 +395,9 @@ function CanvasBlock(props: { return (
- {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} - {!props.preview && permissions.write && } + {!props.preview && permissions.write && ( + + )} +