a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+6097 -3396
actions
app
components
lexicons
api
types
pub
leaflet
pub
leaflet
src
src
supabase
+14 -12
actions/deleteLeaflet.ts
··· 53 } 54 55 // Check if there's a standalone published document 56 - const leafletDoc = tokenData.leaflets_to_documents; 57 - if (leafletDoc && leafletDoc.document) { 58 - if (!identity || !identity.atp_did) { 59 throw new Error( 60 "Unauthorized: You must be logged in to delete a published leaflet", 61 ); 62 } 63 - const docUri = leafletDoc.documents?.uri; 64 - // Extract the DID from the document URI (format: at://did:plc:xxx/...) 65 - if (docUri && !docUri.includes(identity.atp_did)) { 66 - throw new Error( 67 - "Unauthorized: You must own the published document to delete this leaflet", 68 - ); 69 } 70 } 71 } ··· 81 .where(eq(permission_tokens.id, permission_token.id)); 82 83 if (!token?.permission_token_rights?.write) return; 84 - const entitySet = token.permission_token_rights.entity_set; 85 - if (!entitySet) return; 86 - await tx.delete(entities).where(eq(entities.set, entitySet)); 87 await tx 88 .delete(permission_tokens) 89 .where(eq(permission_tokens.id, permission_token.id));
··· 53 } 54 55 // Check if there's a standalone published document 56 + const leafletDocs = tokenData.leaflets_to_documents || []; 57 + if (leafletDocs.length > 0) { 58 + if (!identity) { 59 throw new Error( 60 "Unauthorized: You must be logged in to delete a published leaflet", 61 ); 62 } 63 + for (let leafletDoc of leafletDocs) { 64 + const docUri = leafletDoc.documents?.uri; 65 + // Extract the DID from the document URI (format: at://did:plc:xxx/...) 66 + if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) { 67 + throw new Error( 68 + "Unauthorized: You must own the published document to delete this leaflet", 69 + ); 70 + } 71 } 72 } 73 } ··· 83 .where(eq(permission_tokens.id, permission_token.id)); 84 85 if (!token?.permission_token_rights?.write) return; 86 + await tx 87 + .delete(entities) 88 + .where(eq(entities.set, token.permission_token_rights.entity_set)); 89 await tx 90 .delete(permission_tokens) 91 .where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
··· 11 ) { 12 let identity = await getIdentityData(); 13 if (!identity || !identity.atp_did) return null; 14 - 15 - // Verify publication ownership 16 let { data: publication } = await supabaseServerClient 17 .from("publications") 18 .select("*") ··· 20 .single(); 21 if (publication?.identity_did !== identity.atp_did) return; 22 23 - // Save as a publication draft 24 await supabaseServerClient.from("leaflets_in_publications").insert({ 25 publication: publication_uri, 26 leaflet: leaflet_id,
··· 11 ) { 12 let identity = await getIdentityData(); 13 if (!identity || !identity.atp_did) return null; 14 let { data: publication } = await supabaseServerClient 15 .from("publications") 16 .select("*") ··· 18 .single(); 19 if (publication?.identity_did !== identity.atp_did) return; 20 21 await supabaseServerClient.from("leaflets_in_publications").insert({ 22 publication: publication_uri, 23 leaflet: leaflet_id,
-26
actions/publications/saveLeafletDraft.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function saveLeafletDraft( 7 - leaflet_id: string, 8 - metadata: { title: string; description: string }, 9 - entitiesToDelete: string[], 10 - ) { 11 - let identity = await getIdentityData(); 12 - if (!identity || !identity.atp_did) return null; 13 - 14 - // Save as a looseleaf draft in leaflets_to_documents with null document 15 - await supabaseServerClient.from("leaflets_to_documents").upsert({ 16 - leaflet: leaflet_id, 17 - document: null, 18 - title: metadata.title, 19 - description: metadata.description, 20 - }); 21 - 22 - await supabaseServerClient 23 - .from("entities") 24 - .delete() 25 - .in("id", entitiesToDelete); 26 - }
···
+193 -19
actions/publishToPublication.ts
··· 32 import { scanIndexLocal } from "src/replicache/utils"; 33 import type { Fact } from "src/replicache"; 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 39 import { ids } from "lexicons/api/lexicons"; 40 import { BlobRef } from "@atproto/lexicon"; 41 import { AtUri } from "@atproto/syntax"; ··· 50 ColorToRGBA, 51 } from "components/ThemeManager/colorToLexicons"; 52 import { parseColor } from "@react-stately/color"; 53 54 export async function publishToPublication({ 55 root_entity, ··· 57 leaflet_id, 58 title, 59 description, 60 entitiesToDelete, 61 }: { 62 root_entity: string; ··· 64 leaflet_id: string; 65 title?: string; 66 description?: string; 67 entitiesToDelete?: string[]; 68 }) { 69 const oauthClient = await createOauthClient(); ··· 143 ...(theme && { theme }), 144 title: title || "Untitled", 145 description: description || "", 146 pages: pages.map((p) => { 147 if (p.type === "canvas") { 148 return { ··· 210 } 211 } 212 213 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 214 } 215 ··· 298 if (!b) return []; 299 let block: PubLeafletPagesLinearDocument.Block = { 300 $type: "pub.leaflet.pages.linearDocument#block", 301 - alignment, 302 block: b, 303 }; 304 return [block]; 305 } else { 306 let block: PubLeafletPagesLinearDocument.Block = { ··· 342 Y.applyUpdate(doc, update); 343 let nodes = doc.getXmlElement("prosemirror").toArray(); 344 let stringValue = YJSFragmentToString(nodes[0]); 345 - let facets = YJSFragmentToFacets(nodes[0]); 346 return [stringValue, facets] as const; 347 }; 348 if (b.type === "card") { ··· 398 let [stringValue, facets] = getBlockContent(b.value); 399 let block: $Typed<PubLeafletBlocksHeader.Main> = { 400 $type: "pub.leaflet.blocks.header", 401 - level: headingLevel?.data.value || 1, 402 plaintext: stringValue, 403 facets, 404 }; ··· 431 let block: $Typed<PubLeafletBlocksIframe.Main> = { 432 $type: "pub.leaflet.blocks.iframe", 433 url: url.data.value, 434 - height: height?.data.value || 600, 435 }; 436 return block; 437 } ··· 445 $type: "pub.leaflet.blocks.image", 446 image: blobref, 447 aspectRatio: { 448 - height: image.data.height, 449 - width: image.data.width, 450 }, 451 alt: altText ? altText.data.value : undefined, 452 }; ··· 603 604 function YJSFragmentToFacets( 605 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 606 - ): PubLeafletRichtextFacet.Main[] { 607 if (node.constructor === Y.XmlElement) { 608 - return node 609 - .toArray() 610 - .map((f) => YJSFragmentToFacets(f)) 611 - .flat(); 612 } 613 if (node.constructor === Y.XmlText) { 614 let facets: PubLeafletRichtextFacet.Main[] = []; 615 let delta = node.toDelta() as Delta[]; 616 - let byteStart = 0; 617 for (let d of delta) { 618 let unicodestring = new UnicodeString(d.insert); 619 let facet: PubLeafletRichtextFacet.Main = { ··· 646 }); 647 if (facet.features.length > 0) facets.push(facet); 648 byteStart += unicodestring.length; 649 } 650 - return facets; 651 } 652 - return []; 653 } 654 655 type ExcludeString<T> = T extends string ··· 712 image: blob.data.blob, 713 repeat: backgroundImageRepeat?.data.value ? true : false, 714 ...(backgroundImageRepeat?.data.value && { 715 - width: backgroundImageRepeat.data.value, 716 }), 717 }; 718 } ··· 725 726 return undefined; 727 }
··· 32 import { scanIndexLocal } from "src/replicache/utils"; 33 import type { Fact } from "src/replicache"; 34 import type { Attribute } from "src/replicache/attributes"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 36 import { ids } from "lexicons/api/lexicons"; 37 import { BlobRef } from "@atproto/lexicon"; 38 import { AtUri } from "@atproto/syntax"; ··· 47 ColorToRGBA, 48 } from "components/ThemeManager/colorToLexicons"; 49 import { parseColor } from "@react-stately/color"; 50 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 + import { v7 } from "uuid"; 52 53 export async function publishToPublication({ 54 root_entity, ··· 56 leaflet_id, 57 title, 58 description, 59 + tags, 60 entitiesToDelete, 61 }: { 62 root_entity: string; ··· 64 leaflet_id: string; 65 title?: string; 66 description?: string; 67 + tags?: string[]; 68 entitiesToDelete?: string[]; 69 }) { 70 const oauthClient = await createOauthClient(); ··· 144 ...(theme && { theme }), 145 title: title || "Untitled", 146 description: description || "", 147 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 pages: pages.map((p) => { 149 if (p.type === "canvas") { 150 return { ··· 212 } 213 } 214 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 218 + } 219 + 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 221 } 222 ··· 305 if (!b) return []; 306 let block: PubLeafletPagesLinearDocument.Block = { 307 $type: "pub.leaflet.pages.linearDocument#block", 308 block: b, 309 }; 310 + if (alignment) block.alignment = alignment; 311 return [block]; 312 } else { 313 let block: PubLeafletPagesLinearDocument.Block = { ··· 349 Y.applyUpdate(doc, update); 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 351 let stringValue = YJSFragmentToString(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 353 return [stringValue, facets] as const; 354 }; 355 if (b.type === "card") { ··· 405 let [stringValue, facets] = getBlockContent(b.value); 406 let block: $Typed<PubLeafletBlocksHeader.Main> = { 407 $type: "pub.leaflet.blocks.header", 408 + level: Math.floor(headingLevel?.data.value || 1), 409 plaintext: stringValue, 410 facets, 411 }; ··· 438 let block: $Typed<PubLeafletBlocksIframe.Main> = { 439 $type: "pub.leaflet.blocks.iframe", 440 url: url.data.value, 441 + height: Math.floor(height?.data.value || 600), 442 }; 443 return block; 444 } ··· 452 $type: "pub.leaflet.blocks.image", 453 image: blobref, 454 aspectRatio: { 455 + height: Math.floor(image.data.height), 456 + width: Math.floor(image.data.width), 457 }, 458 alt: altText ? altText.data.value : undefined, 459 }; ··· 610 611 function YJSFragmentToFacets( 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 615 if (node.constructor === Y.XmlElement) { 616 + // Handle inline mention nodes 617 + if (node.nodeName === "didMention") { 618 + const text = node.getAttribute("text") || ""; 619 + const unicodestring = new UnicodeString(text); 620 + const facet: PubLeafletRichtextFacet.Main = { 621 + index: { 622 + byteStart: byteOffset, 623 + byteEnd: byteOffset + unicodestring.length, 624 + }, 625 + features: [ 626 + { 627 + $type: "pub.leaflet.richtext.facet#didMention", 628 + did: node.getAttribute("did"), 629 + }, 630 + ], 631 + }; 632 + return { facets: [facet], byteLength: unicodestring.length }; 633 + } 634 + 635 + if (node.nodeName === "atMention") { 636 + const text = node.getAttribute("text") || ""; 637 + const unicodestring = new UnicodeString(text); 638 + const facet: PubLeafletRichtextFacet.Main = { 639 + index: { 640 + byteStart: byteOffset, 641 + byteEnd: byteOffset + unicodestring.length, 642 + }, 643 + features: [ 644 + { 645 + $type: "pub.leaflet.richtext.facet#atMention", 646 + atURI: node.getAttribute("atURI"), 647 + }, 648 + ], 649 + }; 650 + return { facets: [facet], byteLength: unicodestring.length }; 651 + } 652 + 653 + if (node.nodeName === "hard_break") { 654 + const unicodestring = new UnicodeString("\n"); 655 + return { facets: [], byteLength: unicodestring.length }; 656 + } 657 + 658 + // For other elements (like paragraph), process children 659 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 660 + let currentOffset = byteOffset; 661 + for (const child of node.toArray()) { 662 + const result = YJSFragmentToFacets(child, currentOffset); 663 + allFacets.push(...result.facets); 664 + currentOffset += result.byteLength; 665 + } 666 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 667 } 668 + 669 if (node.constructor === Y.XmlText) { 670 let facets: PubLeafletRichtextFacet.Main[] = []; 671 let delta = node.toDelta() as Delta[]; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 674 for (let d of delta) { 675 let unicodestring = new UnicodeString(d.insert); 676 let facet: PubLeafletRichtextFacet.Main = { ··· 703 }); 704 if (facet.features.length > 0) facets.push(facet); 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 707 } 708 + return { facets, byteLength: totalLength }; 709 } 710 + return { facets: [], byteLength: 0 }; 711 } 712 713 type ExcludeString<T> = T extends string ··· 770 image: blob.data.blob, 771 repeat: backgroundImageRepeat?.data.value ? true : false, 772 ...(backgroundImageRepeat?.data.value && { 773 + width: Math.floor(backgroundImageRepeat.data.value), 774 }), 775 }; 776 } ··· 783 784 return undefined; 785 } 786 + 787 + /** 788 + * Extract mentions from a published document and create notifications 789 + */ 790 + async function createMentionNotifications( 791 + documentUri: string, 792 + record: PubLeafletDocument.Record, 793 + authorDid: string, 794 + ) { 795 + const mentionedDids = new Set<string>(); 796 + const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 797 + const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 798 + 799 + // Extract mentions from all text blocks in all pages 800 + for (const page of record.pages) { 801 + if (page.$type === "pub.leaflet.pages.linearDocument") { 802 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 803 + for (const blockWrapper of linearPage.blocks) { 804 + const block = blockWrapper.block; 805 + if (block.$type === "pub.leaflet.blocks.text") { 806 + const textBlock = block as PubLeafletBlocksText.Main; 807 + if (textBlock.facets) { 808 + for (const facet of textBlock.facets) { 809 + for (const feature of facet.features) { 810 + // Check for DID mentions 811 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 812 + if (feature.did !== authorDid) { 813 + mentionedDids.add(feature.did); 814 + } 815 + } 816 + // Check for AT URI mentions (publications and documents) 817 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 818 + const uri = new AtUri(feature.atURI); 819 + 820 + if (uri.collection === "pub.leaflet.publication") { 821 + // Get the publication owner's DID 822 + const { data: publication } = await supabaseServerClient 823 + .from("publications") 824 + .select("identity_did") 825 + .eq("uri", feature.atURI) 826 + .single(); 827 + 828 + if (publication && publication.identity_did !== authorDid) { 829 + mentionedPublications.set(publication.identity_did, feature.atURI); 830 + } 831 + } else if (uri.collection === "pub.leaflet.document") { 832 + // Get the document owner's DID 833 + const { data: document } = await supabaseServerClient 834 + .from("documents") 835 + .select("uri, data") 836 + .eq("uri", feature.atURI) 837 + .single(); 838 + 839 + if (document) { 840 + const docRecord = document.data as PubLeafletDocument.Record; 841 + if (docRecord.author !== authorDid) { 842 + mentionedDocuments.set(docRecord.author, feature.atURI); 843 + } 844 + } 845 + } 846 + } 847 + } 848 + } 849 + } 850 + } 851 + } 852 + } 853 + } 854 + 855 + // Create notifications for DID mentions 856 + for (const did of mentionedDids) { 857 + const notification: Notification = { 858 + id: v7(), 859 + recipient: did, 860 + data: { 861 + type: "mention", 862 + document_uri: documentUri, 863 + mention_type: "did", 864 + }, 865 + }; 866 + await supabaseServerClient.from("notifications").insert(notification); 867 + await pingIdentityToUpdateNotification(did); 868 + } 869 + 870 + // Create notifications for publication mentions 871 + for (const [recipientDid, publicationUri] of mentionedPublications) { 872 + const notification: Notification = { 873 + id: v7(), 874 + recipient: recipientDid, 875 + data: { 876 + type: "mention", 877 + document_uri: documentUri, 878 + mention_type: "publication", 879 + mentioned_uri: publicationUri, 880 + }, 881 + }; 882 + await supabaseServerClient.from("notifications").insert(notification); 883 + await pingIdentityToUpdateNotification(recipientDid); 884 + } 885 + 886 + // Create notifications for document mentions 887 + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 888 + const notification: Notification = { 889 + id: v7(), 890 + recipient: recipientDid, 891 + data: { 892 + type: "mention", 893 + document_uri: documentUri, 894 + mention_type: "document", 895 + mentioned_uri: mentionedDocUri, 896 + }, 897 + }; 898 + await supabaseServerClient.from("notifications").insert(notification); 899 + await pingIdentityToUpdateNotification(recipientDid); 900 + } 901 + }
+25
actions/searchTags.ts
···
··· 1 + "use server"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export type TagSearchResult = { 5 + name: string; 6 + document_count: number; 7 + }; 8 + 9 + export async function searchTags( 10 + query: string, 11 + ): Promise<TagSearchResult[] | null> { 12 + const searchQuery = query.trim().toLowerCase(); 13 + 14 + // Use raw SQL query to extract and aggregate tags 15 + const { data, error } = await supabaseServerClient.rpc("search_tags", { 16 + search_query: searchQuery, 17 + }); 18 + 19 + if (error) { 20 + console.error("Error searching tags:", error); 21 + return null; 22 + } 23 + 24 + return data; 25 + }
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 import { PubIcon } from "components/ActionBar/Publications"; 5 import { Separator } from "components/Layout"; 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 5 import { PubIcon } from "components/ActionBar/Publications"; 6 import { Separator } from "components/Layout"; 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+7 -92
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 1 "use client"; 2 3 - import { Action } from "@vercel/sdk/esm/models/userevent"; 4 import { createNewLeaflet } from "actions/createNewLeaflet"; 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 import { AddTiny } from "components/Icons/AddTiny"; 7 - import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 8 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 9 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 10 - import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 11 - import { Menu, MenuItem, Separator } from "components/Layout"; 12 import { useIsMobile } from "src/hooks/isMobile"; 13 - import { useIdentityData } from "components/IdentityProvider"; 14 - import { PubIcon } from "components/ActionBar/Publications"; 15 - import { PubLeafletPublication } from "lexicons/api"; 16 - import { createPublicationDraft } from "actions/createPublicationDraft"; 17 - import { useRouter } from "next/navigation"; 18 - import Link from "next/link"; 19 20 export const CreateNewLeafletButton = (props: {}) => { 21 let isMobile = useIsMobile(); ··· 27 } 28 }; 29 return ( 30 - <div className="flex gap-0 flex-row w-full"> 31 - <ActionButton 32 - id="new-leaflet-button" 33 - primary 34 - icon=<AddTiny className="m-1 shrink-0" /> 35 - label="New" 36 - className="grow rounded-r-none sm:ml-0! sm:mr-0! ml-1! mr-0!" 37 - onClick={async () => { 38 - let id = await createNewLeaflet({ 39 - pageType: "doc", 40 - redirectUser: false, 41 - }); 42 - openNewLeaflet(id); 43 - }} 44 - /> 45 - <Separator /> 46 - <CreateNewMoreOptionsButton /> 47 - </div> 48 - ); 49 - }; 50 - 51 - export const CreateNewMoreOptionsButton = (props: {}) => { 52 - let { identity } = useIdentityData(); 53 - 54 - let isMobile = useIsMobile(); 55 - let openNewLeaflet = (id: string) => { 56 - if (isMobile) { 57 - window.location.href = `/${id}?focusFirstBlock`; 58 - } else { 59 - window.open(`/${id}?focusFirstBlock`, "_blank"); 60 - } 61 - }; 62 - 63 - return ( 64 <Menu 65 asChild 66 side={isMobile ? "top" : "right"} 67 align={isMobile ? "center" : "start"} 68 - className="py-2" 69 trigger={ 70 <ActionButton 71 - id="new-leaflet-more-options" 72 primary 73 - icon=<ArrowDownTiny className="m-1 shrink-0 sm:-rotate-90 rotate-180" /> 74 - className="shrink-0 rounded-l-none w-[34px]! sm:mr-0! sm:ml-0! mr-1! ml-0!" 75 /> 76 } 77 > 78 <MenuItem 79 - className="leading-snug" 80 onSelect={async () => { 81 let id = await createNewLeaflet({ 82 pageType: "doc", ··· 85 openNewLeaflet(id); 86 }} 87 > 88 - <BlockDocPageSmall /> 89 <div className="flex flex-col"> 90 - <div>Doc</div> 91 <div className="text-tertiary text-sm font-normal"> 92 A good ol&apos; text document 93 </div> 94 </div> 95 </MenuItem> 96 <MenuItem 97 - className="leading-snug" 98 onSelect={async () => { 99 let id = await createNewLeaflet({ 100 pageType: "canvas", ··· 105 > 106 <BlockCanvasPageSmall /> 107 <div className="flex flex-col"> 108 - Canvas 109 <div className="text-tertiary text-sm font-normal"> 110 A digital whiteboard 111 </div> 112 </div> 113 </MenuItem> 114 - {identity && identity.atp_did && ( 115 - <> 116 - <hr className="border-border-light mt-2 mb-1 -mx-1" /> 117 - <div className="mx-2 text-sm text-tertiary font-bold"> 118 - AT Proto Draft 119 - </div> 120 - <MenuItem className="leading-snug" onSelect={async () => {}}> 121 - <LooseLeafSmall /> 122 - <div className="flex flex-col"> 123 - Looseleaf 124 - <div className="text-tertiary text-sm font-normal"> 125 - A one off post on AT Proto 126 - </div> 127 - </div> 128 - </MenuItem> 129 - {identity?.publications && identity.publications.length > 0 && ( 130 - <> 131 - <hr className="border-border-light border-dashed mx-2 my-0.5" /> 132 - {identity?.publications.map((pub) => { 133 - let router = useRouter(); 134 - return ( 135 - <MenuItem 136 - onSelect={async () => { 137 - let newLeaflet = await createPublicationDraft(pub.uri); 138 - router.push(`/${newLeaflet}`); 139 - }} 140 - > 141 - <PubIcon 142 - record={pub.record as PubLeafletPublication.Record} 143 - uri={pub.uri} 144 - /> 145 - {pub.name} 146 - </MenuItem> 147 - ); 148 - })} 149 - </> 150 - )} 151 - </> 152 - )} 153 </Menu> 154 ); 155 };
··· 1 "use client"; 2 3 import { createNewLeaflet } from "actions/createNewLeaflet"; 4 import { ActionButton } from "components/ActionBar/ActionButton"; 5 import { AddTiny } from "components/Icons/AddTiny"; 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 + import { Menu, MenuItem } from "components/Layout"; 9 import { useIsMobile } from "src/hooks/isMobile"; 10 11 export const CreateNewLeafletButton = (props: {}) => { 12 let isMobile = useIsMobile(); ··· 18 } 19 }; 20 return ( 21 <Menu 22 asChild 23 side={isMobile ? "top" : "right"} 24 align={isMobile ? "center" : "start"} 25 trigger={ 26 <ActionButton 27 + id="new-leaflet-button" 28 primary 29 + icon=<AddTiny className="m-1 shrink-0" /> 30 + label="New" 31 /> 32 } 33 > 34 <MenuItem 35 onSelect={async () => { 36 let id = await createNewLeaflet({ 37 pageType: "doc", ··· 40 openNewLeaflet(id); 41 }} 42 > 43 + <BlockDocPageSmall />{" "} 44 <div className="flex flex-col"> 45 + <div>New Doc</div> 46 <div className="text-tertiary text-sm font-normal"> 47 A good ol&apos; text document 48 </div> 49 </div> 50 </MenuItem> 51 <MenuItem 52 onSelect={async () => { 53 let id = await createNewLeaflet({ 54 pageType: "canvas", ··· 59 > 60 <BlockCanvasPageSmall /> 61 <div className="flex flex-col"> 62 + New Canvas 63 <div className="text-tertiary text-sm font-normal"> 64 A digital whiteboard 65 </div> 66 </div> 67 </MenuItem> 68 </Menu> 69 ); 70 };
+10 -14
app/(home-pages)/home/HomeLayout.tsx
··· 29 HomeEmptyState, 30 PublicationBanner, 31 } from "./HomeEmpty/HomeEmpty"; 32 - import { EmptyState } from "components/EmptyState"; 33 34 export type Leaflet = { 35 added_at: string; ··· 136 (acc, tok) => { 137 let title = 138 tok.permission_tokens.leaflets_in_publications[0]?.title || 139 - tok.permission_tokens.leaflets_to_documents?.title; 140 if (title) acc[tok.permission_tokens.root_entity] = title; 141 return acc; 142 }, ··· 212 className={` 213 leafletList 214 w-full 215 - ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 "} `} 216 > 217 - {searchedLeaflets.length === 0 && ( 218 - <EmptyState> 219 - <div className="italic">Oh no! No results!</div> 220 - </EmptyState> 221 - )} 222 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 223 <ReplicacheProvider 224 disablePull ··· 233 value={{ 234 ...leaflet, 235 leaflets_in_publications: leaflet.leaflets_in_publications || [], 236 - leaflets_to_documents: leaflet.leaflets_to_documents || null, 237 blocked_by_admin: null, 238 custom_domain_routes: [], 239 }} ··· 292 ({ token: leaflet, archived: archived }) => { 293 let published = 294 !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 295 - !!leaflet.leaflets_to_documents?.document; 296 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 - // If no filters are active, show all 299 if ( 300 !filter.drafts && 301 !filter.published && ··· 304 ) 305 return archived === false || archived === null || archived == undefined; 306 307 return ( 308 - (filter.drafts && drafts) || 309 - (filter.published && published) || 310 - (filter.docs && docs) || 311 (filter.archived && archived) 312 ); 313 },
··· 29 HomeEmptyState, 30 PublicationBanner, 31 } from "./HomeEmpty/HomeEmpty"; 32 33 export type Leaflet = { 34 added_at: string; ··· 135 (acc, tok) => { 136 let title = 137 tok.permission_tokens.leaflets_in_publications[0]?.title || 138 + tok.permission_tokens.leaflets_to_documents[0]?.title; 139 if (title) acc[tok.permission_tokens.root_entity] = title; 140 return acc; 141 }, ··· 211 className={` 212 leafletList 213 w-full 214 + ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `} 215 > 216 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 217 <ReplicacheProvider 218 disablePull ··· 227 value={{ 228 ...leaflet, 229 leaflets_in_publications: leaflet.leaflets_in_publications || [], 230 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 231 blocked_by_admin: null, 232 custom_domain_routes: [], 233 }} ··· 286 ({ token: leaflet, archived: archived }) => { 287 let published = 288 !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 289 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 290 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 291 let docs = !leaflet.leaflets_in_publications?.length && !archived; 292 + 293 + // If no filters are active, show everything that is not archived 294 if ( 295 !filter.drafts && 296 !filter.published && ··· 299 ) 300 return archived === false || archived === null || archived == undefined; 301 302 + //if a filter is on, return itemsd of that filter that are also NOT archived 303 return ( 304 + (filter.drafts && drafts && !archived) || 305 + (filter.published && published && !archived) || 306 + (filter.docs && docs && !archived) || 307 (filter.archived && archived) 308 ); 309 },
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 108 await archivePost(tokenId); 109 toaster({ 110 content: ( 111 - <div className="font-bold flex gap-2"> 112 Archived {itemType}! 113 <ButtonTertiary 114 className="underline text-accent-2!"
··· 108 await archivePost(tokenId); 109 toaster({ 110 content: ( 111 + <div className="font-bold flex gap-2 items-center"> 112 Archived {itemType}! 113 <ButtonTertiary 114 className="underline text-accent-2!"
+1 -1
app/(home-pages)/home/page.tsx
··· 30 (acc, tok) => { 31 let title = 32 tok.permission_tokens.leaflets_in_publications[0]?.title || 33 - tok.permission_tokens.leaflets_to_documents?.title; 34 if (title) acc[tok.permission_tokens.root_entity] = title; 35 return acc; 36 },
··· 30 (acc, tok) => { 31 let title = 32 tok.permission_tokens.leaflets_in_publications[0]?.title || 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 34 if (title) acc[tok.permission_tokens.root_entity] = title; 35 return acc; 36 },
+5 -50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 "use client"; 2 - import { 3 - DashboardLayout, 4 - PublicationDashboardControls, 5 - } from "components/PageLayouts/DashboardLayout"; 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 import { useState } from "react"; 8 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 14 import useSWR from "swr"; 15 import { getHomeDocs } from "../home/storage"; 16 import { Leaflet, LeafletList } from "../home/HomeLayout"; 17 - import { EmptyState } from "components/EmptyState"; 18 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 19 20 export const LooseleafsLayout = (props: { 21 entityID: string | null; ··· 41 id="looseleafs" 42 cardBorderHidden={cardBorderHidden} 43 currentPage="looseleafs" 44 - defaultTab="Drafts" 45 actions={<Actions />} 46 tabs={{ 47 - Drafts: { 48 - controls: ( 49 - <PublicationDashboardControls 50 - defaultDisplay={"list"} 51 - hasBackgroundImage={cardBorderHidden} 52 - searchValue={searchValue} 53 - setSearchValueAction={setSearchValue} 54 - /> 55 - ), 56 - content: <LooseleafDraftList empty={true} />, 57 - }, 58 - Published: { 59 controls: null, 60 content: ( 61 <LooseleafList ··· 71 ); 72 }; 73 74 - const LooseleafDraftList = (props: { empty: boolean }) => { 75 - if (props.empty) 76 - return ( 77 - <EmptyState className="pt-2"> 78 - <div className="italic">You don't have any looseleaf drafts yetโ€ฆ</div> 79 - <ButtonPrimary className="mx-auto">New Draft</ButtonPrimary> 80 - </EmptyState> 81 - ); 82 - return ( 83 - <div className="flex flex-col"> 84 - <ButtonSecondary fullWidth>New Looseleaf Draft</ButtonSecondary> 85 - This is where the draft would go if we had them lol 86 - </div> 87 - ); 88 - }; 89 - 90 export const LooseleafList = (props: { 91 titles: { [root_entity: string]: string }; 92 initialFacts: { ··· 111 (acc, tok) => { 112 let title = 113 tok.permission_tokens.leaflets_in_publications[0]?.title || 114 - tok.permission_tokens.leaflets_to_documents?.title; 115 if (title) acc[tok.permission_tokens.root_entity] = title; 116 return acc; 117 }, ··· 127 let leaflets: Leaflet[] = identity 128 ? identity.permission_token_on_homepage 129 .filter( 130 - (ptoh) => 131 - ptoh.permission_tokens.leaflets_to_documents && 132 - ptoh.permission_tokens.leaflets_to_documents.document, 133 ) 134 .map((ptoh) => ({ 135 added_at: ptoh.created_at, 136 token: ptoh.permission_tokens as PermissionToken, 137 })) 138 : []; 139 - 140 - if (!leaflets || leaflets.length === 0) 141 - return ( 142 - <EmptyState> 143 - <div className="italic">You haven't published any looseleafs yet.</div> 144 - <ButtonPrimary className="mx-auto"> 145 - Start a Looseleaf Draft 146 - </ButtonPrimary> 147 - </EmptyState> 148 - ); 149 - 150 return ( 151 <LeafletList 152 defaultDisplay="list"
··· 1 "use client"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 import { useState } from "react"; 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 11 import useSWR from "swr"; 12 import { getHomeDocs } from "../home/storage"; 13 import { Leaflet, LeafletList } from "../home/HomeLayout"; 14 15 export const LooseleafsLayout = (props: { 16 entityID: string | null; ··· 36 id="looseleafs" 37 cardBorderHidden={cardBorderHidden} 38 currentPage="looseleafs" 39 + defaultTab="home" 40 actions={<Actions />} 41 tabs={{ 42 + home: { 43 controls: null, 44 content: ( 45 <LooseleafList ··· 55 ); 56 }; 57 58 export const LooseleafList = (props: { 59 titles: { [root_entity: string]: string }; 60 initialFacts: { ··· 79 (acc, tok) => { 80 let title = 81 tok.permission_tokens.leaflets_in_publications[0]?.title || 82 + tok.permission_tokens.leaflets_to_documents[0]?.title; 83 if (title) acc[tok.permission_tokens.root_entity] = title; 84 return acc; 85 }, ··· 95 let leaflets: Leaflet[] = identity 96 ? identity.permission_token_on_homepage 97 .filter( 98 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 99 ) 100 .map((ptoh) => ({ 101 added_at: ptoh.created_at, 102 token: ptoh.permission_tokens as PermissionToken, 103 })) 104 : []; 105 return ( 106 <LeafletList 107 defaultDisplay="list"
+1 -1
app/(home-pages)/looseleafs/page.tsx
··· 34 (acc, tok) => { 35 let title = 36 tok.permission_tokens.leaflets_in_publications[0]?.title || 37 - tok.permission_tokens.leaflets_to_documents?.title; 38 if (title) acc[tok.permission_tokens.root_entity] = title; 39 return acc; 40 },
··· 34 (acc, tok) => { 35 let title = 36 tok.permission_tokens.leaflets_in_publications[0]?.title || 37 + tok.permission_tokens.leaflets_to_documents[0]?.title; 38 if (title) acc[tok.permission_tokens.root_entity] = title; 39 return acc; 40 },
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
···
··· 1 + import { 2 + AppBskyActorProfile, 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { HydratedCommentMentionNotification } from "src/notifications"; 8 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 + import { MentionTiny } from "components/Icons/MentionTiny"; 10 + import { 11 + CommentInNotification, 12 + ContentLayout, 13 + Notification, 14 + } from "./Notification"; 15 + import { AtUri } from "@atproto/api"; 16 + 17 + export const CommentMentionNotification = ( 18 + props: HydratedCommentMentionNotification, 19 + ) => { 20 + const docRecord = props.commentData.documents 21 + ?.data as PubLeafletDocument.Record; 22 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 + const profileRecord = props.commentData.bsky_profiles 24 + ?.record as AppBskyActorProfile.Record; 25 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 + ?.publications?.record as PubLeafletPublication.Record | undefined; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 30 + 31 + const href = pubRecord 32 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 33 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 + 35 + const commenter = props.commenterHandle 36 + ? `@${props.commenterHandle}` 37 + : "Someone"; 38 + 39 + let actionText: React.ReactNode; 40 + let mentionedDocRecord = props.mentionedDocument 41 + ?.data as PubLeafletDocument.Record; 42 + 43 + if (props.mention_type === "did") { 44 + actionText = <>{commenter} mentioned you in a comment</>; 45 + } else if ( 46 + props.mention_type === "publication" && 47 + props.mentionedPublication 48 + ) { 49 + const mentionedPubRecord = props.mentionedPublication 50 + .record as PubLeafletPublication.Record; 51 + actionText = ( 52 + <> 53 + {commenter} mentioned your publication{" "} 54 + <span className="italic">{mentionedPubRecord.name}</span> in a comment 55 + </> 56 + ); 57 + } else if (props.mention_type === "document" && props.mentionedDocument) { 58 + actionText = ( 59 + <> 60 + {commenter} mentioned your post{" "} 61 + <span className="italic">{mentionedDocRecord.title}</span> in a comment 62 + </> 63 + ); 64 + } else { 65 + actionText = <>{commenter} mentioned you in a comment</>; 66 + } 67 + 68 + return ( 69 + <Notification 70 + timestamp={props.created_at} 71 + href={href} 72 + icon={<MentionTiny />} 73 + actionText={actionText} 74 + content={ 75 + <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 76 + <CommentInNotification 77 + className="" 78 + avatar={ 79 + profileRecord?.avatar?.ref && 80 + blobRefToSrc( 81 + profileRecord?.avatar?.ref, 82 + props.commentData.bsky_profiles?.did || "", 83 + ) 84 + } 85 + displayName={ 86 + profileRecord?.displayName || 87 + props.commentData.bsky_profiles?.handle || 88 + "Someone" 89 + } 90 + index={[]} 91 + plaintext={commentRecord.plaintext} 92 + facets={commentRecord.facets} 93 + /> 94 + </ContentLayout> 95 + } 96 + /> 97 + ); 98 + };
+44 -24
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 import { ContentLayout, Notification } from "./Notification"; 3 - import { HydratedQuoteNotification } from "src/notifications"; 4 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { AtUri } from "@atproto/api"; 6 - import { Avatar } from "components/Avatar"; 7 8 - export const QuoteNotification = (props: HydratedQuoteNotification) => { 9 - const postView = props.bskyPost.post_view as any; 10 - const author = postView.author; 11 - const displayName = author.displayName || author.handle || "Someone"; 12 const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 14 ?.record as PubLeafletPublication.Record | undefined; 15 const docUri = new AtUri(props.document.uri); 16 const rkey = docUri.rkey; 17 const did = docUri.host; 18 - const postText = postView.record?.text || ""; 19 20 const href = pubRecord 21 ? `https://${pubRecord.base_path}/${rkey}` 22 : `/p/${did}/${rkey}`; 23 24 return ( 25 <Notification 26 timestamp={props.created_at} 27 href={href} 28 - icon={<QuoteTiny />} 29 - actionText={<>{displayName} quoted your post</>} 30 content={ 31 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 - <div className="flex gap-2 text-sm w-full"> 33 - <Avatar 34 - src={author.avatar} 35 - displayName={displayName} 36 - /> 37 - <pre 38 - style={{ wordBreak: "break-word" }} 39 - className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 40 - > 41 - {postText} 42 - </pre> 43 - </div> 44 </ContentLayout> 45 } 46 />
··· 1 + import { MentionTiny } from "components/Icons/MentionTiny"; 2 import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedMentionNotification } from "src/notifications"; 4 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { Agent, AtUri } from "@atproto/api"; 6 7 + export const MentionNotification = (props: HydratedMentionNotification) => { 8 const docRecord = props.document.data as PubLeafletDocument.Record; 9 + const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 ?.record as PubLeafletPublication.Record | undefined; 11 const docUri = new AtUri(props.document.uri); 12 const rkey = docUri.rkey; 13 const did = docUri.host; 14 15 const href = pubRecord 16 ? `https://${pubRecord.base_path}/${rkey}` 17 : `/p/${did}/${rkey}`; 18 19 + let actionText: React.ReactNode; 20 + let mentionedItemName: string | undefined; 21 + let mentionedDocRecord = props.mentionedDocument 22 + ?.data as PubLeafletDocument.Record; 23 + 24 + const mentioner = props.documentCreatorHandle 25 + ? `@${props.documentCreatorHandle}` 26 + : "Someone"; 27 + 28 + if (props.mention_type === "did") { 29 + actionText = <>{mentioner} mentioned you</>; 30 + } else if ( 31 + props.mention_type === "publication" && 32 + props.mentionedPublication 33 + ) { 34 + const mentionedPubRecord = props.mentionedPublication 35 + .record as PubLeafletPublication.Record; 36 + mentionedItemName = mentionedPubRecord.name; 37 + actionText = ( 38 + <> 39 + {mentioner} mentioned your publication{" "} 40 + <span className="italic">{mentionedItemName}</span> 41 + </> 42 + ); 43 + } else if (props.mention_type === "document" && props.mentionedDocument) { 44 + mentionedItemName = mentionedDocRecord.title; 45 + actionText = ( 46 + <> 47 + {mentioner} mentioned your post{" "} 48 + <span className="italic">{mentionedItemName}</span> 49 + </> 50 + ); 51 + } else { 52 + actionText = <>{mentioner} mentioned you</>; 53 + } 54 + 55 return ( 56 <Notification 57 timestamp={props.created_at} 58 href={href} 59 + icon={<MentionTiny />} 60 + actionText={actionText} 61 content={ 62 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 63 + {docRecord.description && docRecord.description} 64 </ContentLayout> 65 } 66 />
+3 -3
app/(home-pages)/notifications/Notification.tsx
··· 69 <div 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 > 72 - <div className="text-tertiary text-sm italic font-bold pb-1"> 73 {props.postTitle} 74 </div> 75 - {props.children} 76 {props.pubRecord && ( 77 <> 78 - <hr className="mt-3 mb-1 border-border-light" /> 79 <a 80 href={`https://${props.pubRecord.base_path}`} 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
··· 69 <div 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 > 72 + <div className="text-tertiary text-sm italic font-bold "> 73 {props.postTitle} 74 </div> 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 {props.pubRecord && ( 77 <> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 <a 80 href={`https://${props.pubRecord.base_path}`} 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+9 -1
app/(home-pages)/notifications/NotificationList.tsx
··· 7 import { ReplyNotification } from "./ReplyNotification"; 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 - import { QuoteNotification } from "./MentionNotification"; 11 12 export function NotificationList({ 13 notifications, ··· 45 } 46 if (n.type === "quote") { 47 return <QuoteNotification key={n.id} {...n} />; 48 } 49 })} 50 </div>
··· 7 import { ReplyNotification } from "./ReplyNotification"; 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 13 14 export function NotificationList({ 15 notifications, ··· 47 } 48 if (n.type === "quote") { 49 return <QuoteNotification key={n.id} {...n} />; 50 + } 51 + if (n.type === "mention") { 52 + return <MentionNotification key={n.id} {...n} />; 53 + } 54 + if (n.type === "comment_mention") { 55 + return <CommentMentionNotification key={n.id} {...n} />; 56 } 57 })} 58 </div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
···
··· 1 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedQuoteNotification } from "src/notifications"; 4 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Avatar } from "components/Avatar"; 7 + 8 + export const QuoteNotification = (props: HydratedQuoteNotification) => { 9 + const postView = props.bskyPost.post_view as any; 10 + const author = postView.author; 11 + const displayName = author.displayName || author.handle || "Someone"; 12 + const docRecord = props.document.data as PubLeafletDocument.Record; 13 + const pubRecord = props.document.documents_in_publications[0]?.publications 14 + ?.record as PubLeafletPublication.Record | undefined; 15 + const docUri = new AtUri(props.document.uri); 16 + const rkey = docUri.rkey; 17 + const did = docUri.host; 18 + const postText = postView.record?.text || ""; 19 + 20 + const href = pubRecord 21 + ? `https://${pubRecord.base_path}/${rkey}` 22 + : `/p/${did}/${rkey}`; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<QuoteTiny />} 29 + actionText={<>{displayName} quoted your post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + <div className="flex gap-2 text-sm w-full"> 33 + <Avatar 34 + src={author.avatar} 35 + displayName={displayName} 36 + /> 37 + <pre 38 + style={{ wordBreak: "break-word" }} 39 + className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 40 + > 41 + {postText} 42 + </pre> 43 + </div> 44 + </ContentLayout> 45 + } 46 + /> 47 + ); 48 + };
+9 -195
app/(home-pages)/reader/ReaderContent.tsx
··· 1 "use client"; 2 - import { AtUri } from "@atproto/api"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 - import { Separator } from "components/Layout"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { useSmoker } from "components/Toast"; 14 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 - import { Json } from "supabase/database.types"; 17 import type { Cursor, Post } from "./getReaderFeed"; 18 import useSWRInfinite from "swr/infinite"; 19 import { getReaderFeed } from "./getReaderFeed"; 20 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 24 - import { EmptyState } from "components/EmptyState"; 25 26 export const ReaderContent = (props: { 27 posts: Post[]; ··· 29 }) => { 30 const getKey = ( 31 pageIndex: number, 32 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 33 ) => { 34 // Reached the end 35 if (previousPageData && !previousPageData.nextCursor) return null; ··· 41 return ["reader-feed", previousPageData?.nextCursor] as const; 42 }; 43 44 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 45 getKey, 46 ([_, cursor]) => getReaderFeed(cursor), 47 { ··· 80 return ( 81 <div className="flex flex-col gap-3 relative"> 82 {allPosts.map((p) => ( 83 - <Post {...p} key={p.documents.uri} /> 84 ))} 85 {/* Trigger element for loading more posts */} 86 <div ··· 97 ); 98 }; 99 100 - const Post = (props: Post) => { 101 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 102 - 103 - let postRecord = props.documents.data as PubLeafletDocument.Record; 104 - let postUri = new AtUri(props.documents.uri); 105 - 106 - let theme = usePubTheme(pubRecord?.theme); 107 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 108 - ? blobRefToSrc( 109 - pubRecord?.theme?.backgroundImage?.image?.ref, 110 - new AtUri(props.publication.uri).host, 111 - ) 112 - : null; 113 - 114 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 115 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 116 - 117 - let showPageBackground = pubRecord.theme?.showPageBackground; 118 - 119 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 120 - let comments = 121 - pubRecord.preferences?.showComments === false 122 - ? 0 123 - : props.documents.comments_on_documents?.[0]?.count || 0; 124 - 125 - return ( 126 - <BaseThemeProvider {...theme} local> 127 - <div 128 - style={{ 129 - backgroundImage: `url(${backgroundImage})`, 130 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 131 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 132 - }} 133 - className={`no-underline! flex flex-row gap-2 w-full relative 134 - bg-bg-leaflet 135 - border border-border-light rounded-lg 136 - sm:p-2 p-2 selected-outline 137 - hover:outline-accent-contrast hover:border-accent-contrast 138 - `} 139 - > 140 - <a 141 - className="h-full w-full absolute top-0 left-0" 142 - href={`${props.publication.href}/${postUri.rkey}`} 143 - /> 144 - <div 145 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 146 - style={{ 147 - backgroundColor: showPageBackground 148 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 149 - : "transparent", 150 - }} 151 - > 152 - <h3 className="text-primary truncate">{postRecord.title}</h3> 153 - 154 - <p className="text-secondary">{postRecord.description}</p> 155 - <div className="flex gap-2 justify-between items-end"> 156 - <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3"> 157 - <PubInfo 158 - href={props.publication.href} 159 - pubRecord={pubRecord} 160 - uri={props.publication.uri} 161 - /> 162 - <Separator classname="h-4 !min-h-0 md:block hidden" /> 163 - <PostInfo 164 - author={props.author || ""} 165 - publishedAt={postRecord.publishedAt} 166 - /> 167 - </div> 168 - 169 - <PostInterations 170 - postUrl={`${props.publication.href}/${postUri.rkey}`} 171 - quotesCount={quotes} 172 - commentsCount={comments} 173 - showComments={pubRecord.preferences?.showComments} 174 - /> 175 - </div> 176 - </div> 177 - </div> 178 - </BaseThemeProvider> 179 - ); 180 - }; 181 - 182 - const PubInfo = (props: { 183 - href: string; 184 - pubRecord: PubLeafletPublication.Record; 185 - uri: string; 186 - }) => { 187 - return ( 188 - <a 189 - href={props.href} 190 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 191 - > 192 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 193 - {props.pubRecord.name} 194 - </a> 195 - ); 196 - }; 197 - 198 - const PostInfo = (props: { 199 - author: string; 200 - publishedAt: string | undefined; 201 - }) => { 202 - const formattedDate = useLocalizedDate( 203 - props.publishedAt || new Date().toISOString(), 204 - { 205 - year: "numeric", 206 - month: "short", 207 - day: "numeric", 208 - }, 209 - ); 210 - 211 - return ( 212 - <div className="flex flex-wrap gap-2 grow items-center shrink-0"> 213 - {props.author} 214 - {props.publishedAt && ( 215 - <> 216 - <Separator classname="h-4 !min-h-0" /> 217 - {formattedDate}{" "} 218 - </> 219 - )} 220 - </div> 221 - ); 222 - }; 223 - 224 - const PostInterations = (props: { 225 - quotesCount: number; 226 - commentsCount: number; 227 - postUrl: string; 228 - showComments: boolean | undefined; 229 - }) => { 230 - let smoker = useSmoker(); 231 - let interactionsAvailable = 232 - props.quotesCount > 0 || 233 - (props.showComments !== false && props.commentsCount > 0); 234 - 235 - return ( 236 - <div className={`flex gap-2 text-tertiary text-sm items-center`}> 237 - {props.quotesCount === 0 ? null : ( 238 - <div className={`flex gap-1 items-center `} aria-label="Post quotes"> 239 - <QuoteTiny aria-hidden /> {props.quotesCount} 240 - </div> 241 - )} 242 - {props.showComments === false || props.commentsCount === 0 ? null : ( 243 - <div className={`flex gap-1 items-center`} aria-label="Post comments"> 244 - <CommentTiny aria-hidden /> {props.commentsCount} 245 - </div> 246 - )} 247 - {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 248 - <button 249 - id={`copy-post-link-${props.postUrl}`} 250 - className="flex gap-1 items-center hover:font-bold relative" 251 - onClick={(e) => { 252 - e.stopPropagation(); 253 - e.preventDefault(); 254 - let mouseX = e.clientX; 255 - let mouseY = e.clientY; 256 - 257 - if (!props.postUrl) return; 258 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 259 - 260 - smoker({ 261 - text: <strong>Copied Link!</strong>, 262 - position: { 263 - y: mouseY, 264 - x: mouseX, 265 - }, 266 - }); 267 - }} 268 - > 269 - Share 270 - </button> 271 - </div> 272 - ); 273 - }; 274 export const ReaderEmpty = () => { 275 return ( 276 - <EmptyState> 277 Nothing to read yetโ€ฆ <br /> 278 Subscribe to publications and find their posts here! 279 <Link href={"/discover"}> ··· 281 <DiscoverSmall /> Discover Publications 282 </ButtonPrimary> 283 </Link> 284 - </EmptyState> 285 ); 286 };
··· 1 "use client"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 import type { Cursor, Post } from "./getReaderFeed"; 5 import useSWRInfinite from "swr/infinite"; 6 import { getReaderFeed } from "./getReaderFeed"; 7 import { useEffect, useRef } from "react"; 8 import Link from "next/link"; 9 + import { PostListing } from "components/PostListing"; 10 11 export const ReaderContent = (props: { 12 posts: Post[]; ··· 14 }) => { 15 const getKey = ( 16 pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 ) => { 22 // Reached the end 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 29 return ["reader-feed", previousPageData?.nextCursor] as const; 30 }; 31 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 getKey, 34 ([_, cursor]) => getReaderFeed(cursor), 35 { ··· 68 return ( 69 <div className="flex flex-col gap-3 relative"> 70 {allPosts.map((p) => ( 71 + <PostListing {...p} key={p.documents.uri} /> 72 ))} 73 {/* Trigger element for loading more posts */} 74 <div ··· 85 ); 86 }; 87 88 export const ReaderEmpty = () => { 89 return ( 90 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 91 Nothing to read yetโ€ฆ <br /> 92 Subscribe to publications and find their posts here! 93 <Link href={"/discover"}> ··· 95 <DiscoverSmall /> Discover Publications 96 </ButtonPrimary> 97 </Link> 98 + </div> 99 ); 100 };
+2 -3
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 8 import { useEffect, useRef } from "react"; 9 import { Cursor } from "./getReaderFeed"; 10 import Link from "next/link"; 11 - import { EmptyState } from "components/EmptyState"; 12 13 export const SubscriptionsContent = (props: { 14 publications: PublicationSubscription[]; ··· 94 95 export const SubscriptionsEmpty = () => { 96 return ( 97 - <EmptyState> 98 You haven't subscribed to any publications yet! 99 <Link href={"/discover"}> 100 <ButtonPrimary className="mx-auto place-self-center"> 101 <DiscoverSmall /> Discover Publications 102 </ButtonPrimary> 103 </Link> 104 - </EmptyState> 105 ); 106 };
··· 8 import { useEffect, useRef } from "react"; 9 import { Cursor } from "./getReaderFeed"; 10 import Link from "next/link"; 11 12 export const SubscriptionsContent = (props: { 13 publications: PublicationSubscription[]; ··· 93 94 export const SubscriptionsEmpty = () => { 95 return ( 96 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 97 You haven't subscribed to any publications yet! 98 <Link href={"/discover"}> 99 <ButtonPrimary className="mx-auto place-self-center"> 100 <DiscoverSmall /> Discover Publications 101 </ButtonPrimary> 102 </Link> 103 + </div> 104 ); 105 };
+68
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
··· 1 + "use server"; 2 + 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Json } from "supabase/database.types"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 9 + 10 + export async function getDocumentsByTag( 11 + tag: string, 12 + ): Promise<{ posts: Post[] }> { 13 + // Query documents that have this tag 14 + const { data: documents, error } = await supabaseServerClient 15 + .from("documents") 16 + .select( 17 + `*, 18 + comments_on_documents(count), 19 + document_mentions_in_bsky(count), 20 + documents_in_publications(publications(*))`, 21 + ) 22 + .contains("data->tags", `["${tag}"]`) 23 + .order("indexed_at", { ascending: false }) 24 + .limit(50); 25 + 26 + if (error) { 27 + console.error("Error fetching documents by tag:", error); 28 + return { posts: [] }; 29 + } 30 + 31 + const posts = await Promise.all( 32 + documents.map(async (doc) => { 33 + const pub = doc.documents_in_publications[0]?.publications; 34 + 35 + // Skip if document doesn't have a publication 36 + if (!pub) { 37 + return null; 38 + } 39 + 40 + const uri = new AtUri(doc.uri); 41 + const handle = await idResolver.did.resolve(uri.host); 42 + 43 + const post: Post = { 44 + publication: { 45 + href: getPublicationURL(pub), 46 + pubRecord: pub?.record || null, 47 + uri: pub?.uri || "", 48 + }, 49 + author: handle?.alsoKnownAs?.[0] 50 + ? `@${handle.alsoKnownAs[0].slice(5)}` 51 + : null, 52 + documents: { 53 + comments_on_documents: doc.comments_on_documents, 54 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 55 + data: doc.data, 56 + uri: doc.uri, 57 + indexed_at: doc.indexed_at, 58 + }, 59 + }; 60 + return post; 61 + }), 62 + ); 63 + 64 + // Filter out null entries (documents without publications) 65 + return { 66 + posts: posts.filter((p): p is Post => p !== null), 67 + }; 68 + }
+75
app/(home-pages)/tag/[tag]/page.tsx
···
··· 1 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { Tag } from "components/Tags"; 3 + import { PostListing } from "components/PostListing"; 4 + import { getDocumentsByTag } from "./getDocumentsByTag"; 5 + import { TagTiny } from "components/Icons/TagTiny"; 6 + 7 + export default async function TagPage(props: { 8 + params: Promise<{ tag: string }>; 9 + }) { 10 + const params = await props.params; 11 + const decodedTag = decodeURIComponent(params.tag); 12 + const { posts } = await getDocumentsByTag(decodedTag); 13 + 14 + return ( 15 + <DashboardLayout 16 + id="tag" 17 + cardBorderHidden={false} 18 + currentPage="tag" 19 + defaultTab="default" 20 + actions={null} 21 + tabs={{ 22 + default: { 23 + controls: null, 24 + content: <TagContent tag={decodedTag} posts={posts} />, 25 + }, 26 + }} 27 + /> 28 + ); 29 + } 30 + 31 + const TagContent = (props: { 32 + tag: string; 33 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 34 + }) => { 35 + return ( 36 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 37 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 38 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 39 + </div> 40 + <div className="pt-6 flex flex-col gap-3"> 41 + {props.posts.length === 0 ? ( 42 + <EmptyState tag={props.tag} /> 43 + ) : ( 44 + props.posts.map((post) => ( 45 + <PostListing key={post.documents.uri} {...post} /> 46 + )) 47 + )} 48 + </div> 49 + </div> 50 + ); 51 + }; 52 + 53 + const TagHeader = (props: { tag: string; postCount: number }) => { 54 + return ( 55 + <div className="flex flex-col leading-tight items-center"> 56 + <div className="flex items-center gap-3 text-xl font-bold text-primary"> 57 + <TagTiny className="scale-150" /> 58 + <h1>{props.tag}</h1> 59 + </div> 60 + <div className="text-tertiary text-sm"> 61 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 62 + </div> 63 + </div> 64 + ); 65 + }; 66 + 67 + const EmptyState = (props: { tag: string }) => { 68 + return ( 69 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 70 + <div className="text-tertiary"> 71 + No posts found with the tag "{props.tag}" 72 + </div> 73 + </div> 74 + ); 75 + };
+1 -1
app/[leaflet_id]/actions/HomeButton.tsx
··· 53 archived: null, 54 permission_tokens: { 55 ...permission_token, 56 - leaflets_to_documents: null, 57 leaflets_in_publications: [], 58 }, 59 });
··· 53 archived: null, 54 permission_tokens: { 55 ...permission_token, 56 + leaflets_to_documents: [], 57 leaflets_in_publications: [], 58 }, 59 });
+15 -22
app/[leaflet_id]/actions/PublishButton.tsx
··· 27 import { useState, useMemo } from "react"; 28 import { useIsMobile } from "src/hooks/isMobile"; 29 import { useReplicache, useEntity } from "src/replicache"; 30 import { Json } from "supabase/database.types"; 31 import { 32 useBlocks, ··· 34 } from "src/hooks/queries/useBlocks"; 35 import * as Y from "yjs"; 36 import * as base64 from "base64-js"; 37 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 import { BlueskyLogin } from "app/login/LoginForm"; 39 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 40 - import { saveLeafletDraft } from "actions/publications/saveLeafletDraft"; 41 import { AddTiny } from "components/Icons/AddTiny"; 42 43 export const PublishButton = (props: { entityID: string }) => { ··· 64 const UpdateButton = () => { 65 let [isLoading, setIsLoading] = useState(false); 66 let { data: pub, mutate } = useLeafletPublicationData(); 67 - let { permission_token, rootEntity } = useReplicache(); 68 let { identity } = useIdentityData(); 69 let toaster = useToaster(); 70 71 return ( 72 <ActionButton ··· 82 leaflet_id: permission_token.id, 83 title: pub.title, 84 description: pub.description, 85 }); 86 setIsLoading(false); 87 mutate(); ··· 109 let { identity } = useIdentityData(); 110 let { permission_token } = useReplicache(); 111 let query = useSearchParams(); 112 - console.log(query.get("publish")); 113 let [open, setOpen] = useState(query.get("publish") !== null); 114 115 let isMobile = useIsMobile(); ··· 177 <hr className="border-border-light mt-3 mb-2" /> 178 179 <div className="flex gap-2 items-center place-self-end"> 180 - {selectedPub && selectedPub !== "create" && ( 181 <SaveAsDraftButton 182 selectedPub={selectedPub} 183 leafletId={permission_token.id} ··· 230 if (props.selectedPub === "create") return; 231 e.preventDefault(); 232 setIsLoading(true); 233 - 234 - // Use different actions for looseleaf vs publication 235 - if (props.selectedPub === "looseleaf") { 236 - await saveLeafletDraft( 237 - props.leafletId, 238 - props.metadata, 239 - props.entitiesToDelete, 240 - ); 241 - } else { 242 - await moveLeafletToPublication( 243 - props.leafletId, 244 - props.selectedPub, 245 - props.metadata, 246 - props.entitiesToDelete, 247 - ); 248 - } 249 - 250 await Promise.all([rep?.pull(), mutate()]); 251 setIsLoading(false); 252 }}
··· 27 import { useState, useMemo } from "react"; 28 import { useIsMobile } from "src/hooks/isMobile"; 29 import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 31 import { Json } from "supabase/database.types"; 32 import { 33 useBlocks, ··· 35 } from "src/hooks/queries/useBlocks"; 36 import * as Y from "yjs"; 37 import * as base64 from "base64-js"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 import { AddTiny } from "components/Icons/AddTiny"; 42 43 export const PublishButton = (props: { entityID: string }) => { ··· 64 const UpdateButton = () => { 65 let [isLoading, setIsLoading] = useState(false); 66 let { data: pub, mutate } = useLeafletPublicationData(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 68 let { identity } = useIdentityData(); 69 let toaster = useToaster(); 70 + 71 + // Get tags from Replicache state (same as draft editor) 72 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 + const currentTags = Array.isArray(tags) ? tags : []; 74 75 return ( 76 <ActionButton ··· 86 leaflet_id: permission_token.id, 87 title: pub.title, 88 description: pub.description, 89 + tags: currentTags, 90 }); 91 setIsLoading(false); 92 mutate(); ··· 114 let { identity } = useIdentityData(); 115 let { permission_token } = useReplicache(); 116 let query = useSearchParams(); 117 let [open, setOpen] = useState(query.get("publish") !== null); 118 119 let isMobile = useIsMobile(); ··· 181 <hr className="border-border-light mt-3 mb-2" /> 182 183 <div className="flex gap-2 items-center place-self-end"> 184 + {selectedPub !== "looseleaf" && selectedPub && ( 185 <SaveAsDraftButton 186 selectedPub={selectedPub} 187 leafletId={permission_token.id} ··· 234 if (props.selectedPub === "create") return; 235 e.preventDefault(); 236 setIsLoading(true); 237 + await moveLeafletToPublication( 238 + props.leafletId, 239 + props.selectedPub, 240 + props.metadata, 241 + props.entitiesToDelete, 242 + ); 243 await Promise.all([rep?.pull(), mutate()]); 244 setIsLoading(false); 245 }}
+1 -1
app/[leaflet_id]/page.tsx
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData";
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData";
+144 -294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 "use client"; 2 - import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 - import { 4 - useState, 5 - useCallback, 6 - useRef, 7 - useLayoutEffect, 8 - useEffect, 9 - } from "react"; 10 - import { createPortal } from "react-dom"; 11 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 - import * as Popover from "@radix-ui/react-popover"; 13 - import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 import { EditorView } from "prosemirror-view"; 15 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 import { baseKeymap } from "prosemirror-commands"; ··· 19 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 23 // Schema with only links, mentions, and hashtags marks 24 const bskyPostSchema = new Schema({ ··· 134 return tr; 135 }); 136 } 137 - 138 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 140 initialContent?: string; 141 onCharCountChange?: (count: number) => void; 142 }) { 143 const mountRef = useRef<HTMLDivElement | null>(null); 144 const viewRef = useRef<EditorView | null>(null); 145 const [editorState, setEditorState] = useState<EditorState | null>(null); 146 - const [mentionState, setMentionState] = useState<{ 147 - active: boolean; 148 - range: { from: number; to: number } | null; 149 - selectedMention: { handle: string; did: string } | null; 150 - }>({ active: false, range: null, selectedMention: null }); 151 152 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 157 - if (!viewRef.current) return; 158 const view = viewRef.current; 159 - const { from, to } = range; 160 const tr = view.state.tr; 161 162 - // Delete the query text (keep the @) 163 - tr.delete(from + 1, to); 164 165 - // Insert the mention text after the @ 166 - const mentionText = mention.handle; 167 - tr.insertText(mentionText, from + 1); 168 - 169 - // Apply mention mark to @ and handle 170 - tr.addMark( 171 - from, 172 - from + 1 + mentionText.length, 173 - bskyPostSchema.marks.mention.create({ did: mention.did }), 174 - ); 175 - 176 - // Add a space after the mention 177 - tr.insertText(" ", from + 1 + mentionText.length); 178 179 view.dispatch(tr); 180 view.focus(); 181 }, 182 - [], 183 ); 184 185 - const mentionStateRef = useRef(mentionState); 186 - mentionStateRef.current = mentionState; 187 188 useLayoutEffect(() => { 189 if (!mountRef.current) return; 190 191 const initialState = EditorState.create({ 192 schema: bskyPostSchema, 193 doc: props.initialContent ··· 200 }) 201 : undefined, 202 plugins: [ 203 - inputRules({ rules: [createHashtagInputRule()] }), 204 keymap({ 205 "Mod-z": undo, 206 "Mod-y": redo, 207 "Shift-Mod-z": redo, 208 - Enter: (state, dispatch) => { 209 - // Check if mention autocomplete is active 210 - const currentMentionState = mentionStateRef.current; 211 - if ( 212 - currentMentionState.active && 213 - currentMentionState.selectedMention && 214 - currentMentionState.range 215 - ) { 216 - handleMentionSelect( 217 - currentMentionState.selectedMention, 218 - currentMentionState.range, 219 - ); 220 - return true; 221 - } 222 - // Otherwise let the default Enter behavior happen (new paragraph) 223 - return false; 224 - }, 225 }), 226 keymap(baseKeymap), 227 autolink({ ··· 258 view.destroy(); 259 viewRef.current = null; 260 }; 261 - }, [handleMentionSelect]); 262 263 return ( 264 <div className="relative w-full h-full group"> 265 - {editorState && ( 266 - <MentionAutocomplete 267 - editorState={editorState} 268 - view={viewRef} 269 - onSelect={handleMentionSelect} 270 - onMentionStateChange={(active, range, selectedMention) => { 271 - setMentionState({ active, range, selectedMention }); 272 - }} 273 - /> 274 - )} 275 {editorState?.doc.textContent.length === 0 && ( 276 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 277 Write a post to share your writing! ··· 279 )} 280 <div 281 ref={mountRef} 282 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 283 style={{ 284 wordWrap: "break-word", 285 overflowWrap: "break-word", ··· 290 ); 291 } 292 293 - function MentionAutocomplete(props: { 294 - editorState: EditorState; 295 - view: React.RefObject<EditorView | null>; 296 - onSelect: ( 297 - mention: { handle: string; did: string }, 298 - range: { from: number; to: number }, 299 - ) => void; 300 - onMentionStateChange: ( 301 - active: boolean, 302 - range: { from: number; to: number } | null, 303 - selectedMention: { handle: string; did: string } | null, 304 - ) => void; 305 - }) { 306 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 307 - const [mentionRange, setMentionRange] = useState<{ 308 - from: number; 309 - to: number; 310 - } | null>(null); 311 - const [mentionCoords, setMentionCoords] = useState<{ 312 - top: number; 313 - left: number; 314 - } | null>(null); 315 - 316 - const { suggestionIndex, setSuggestionIndex, suggestions } = 317 - useMentionSuggestions(mentionQuery); 318 - 319 - // Check for mention pattern whenever editor state changes 320 - useEffect(() => { 321 - const { $from } = props.editorState.selection; 322 - const textBefore = $from.parent.textBetween( 323 - Math.max(0, $from.parentOffset - 50), 324 - $from.parentOffset, 325 - null, 326 - "\ufffc", 327 - ); 328 - 329 - // Look for @ followed by word characters before cursor 330 - const match = textBefore.match(/@([\w.]*)$/); 331 - 332 - if (match && props.view.current) { 333 - const queryBefore = match[1]; 334 - const from = $from.pos - queryBefore.length - 1; 335 - 336 - // Get text after cursor to find the rest of the handle 337 - const textAfter = $from.parent.textBetween( 338 - $from.parentOffset, 339 - Math.min($from.parent.content.size, $from.parentOffset + 50), 340 - null, 341 - "\ufffc", 342 - ); 343 - 344 - // Match word characters after cursor until space or end 345 - const afterMatch = textAfter.match(/^([\w.]*)/); 346 - const queryAfter = afterMatch ? afterMatch[1] : ""; 347 - 348 - // Combine the full handle 349 - const query = queryBefore + queryAfter; 350 - const to = $from.pos + queryAfter.length; 351 - 352 - setMentionQuery(query); 353 - setMentionRange({ from, to }); 354 - 355 - // Get coordinates for the autocomplete popup 356 - const coords = props.view.current.coordsAtPos(from); 357 - setMentionCoords({ 358 - top: coords.bottom + window.scrollY, 359 - left: coords.left + window.scrollX, 360 - }); 361 - setSuggestionIndex(0); 362 - } else { 363 - setMentionQuery(null); 364 - setMentionRange(null); 365 - setMentionCoords(null); 366 - } 367 - }, [props.editorState, props.view, setSuggestionIndex]); 368 - 369 - // Update parent's mention state 370 - useEffect(() => { 371 - const active = mentionQuery !== null && suggestions.length > 0; 372 - const selectedMention = 373 - active && suggestions[suggestionIndex] 374 - ? suggestions[suggestionIndex] 375 - : null; 376 - props.onMentionStateChange(active, mentionRange, selectedMention); 377 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 378 - 379 - // Handle keyboard navigation for arrow keys only 380 - useEffect(() => { 381 - if (!mentionQuery || !props.view.current) return; 382 - 383 - const handleKeyDown = (e: KeyboardEvent) => { 384 - if (suggestions.length === 0) return; 385 - 386 - if (e.key === "ArrowUp") { 387 - e.preventDefault(); 388 - if (suggestionIndex > 0) { 389 - setSuggestionIndex((i) => i - 1); 390 - } 391 - } else if (e.key === "ArrowDown") { 392 - e.preventDefault(); 393 - if (suggestionIndex < suggestions.length - 1) { 394 - setSuggestionIndex((i) => i + 1); 395 - } 396 - } 397 - }; 398 - 399 - const dom = props.view.current.dom; 400 - dom.addEventListener("keydown", handleKeyDown); 401 - 402 - return () => { 403 - dom.removeEventListener("keydown", handleKeyDown); 404 - }; 405 - }, [ 406 - mentionQuery, 407 - suggestions, 408 - suggestionIndex, 409 - props.view, 410 - setSuggestionIndex, 411 - ]); 412 - 413 - if (!mentionCoords || suggestions.length === 0) return null; 414 - 415 - // The styles in this component should match the Menu styles in components/Layout.tsx 416 - return ( 417 - <Popover.Root open> 418 - {createPortal( 419 - <Popover.Anchor 420 - style={{ 421 - top: mentionCoords.top, 422 - left: mentionCoords.left, 423 - position: "absolute", 424 - }} 425 - />, 426 - document.body, 427 - )} 428 - <Popover.Portal> 429 - <Popover.Content 430 - side="bottom" 431 - align="start" 432 - sideOffset={4} 433 - collisionPadding={20} 434 - onOpenAutoFocus={(e) => e.preventDefault()} 435 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 436 - > 437 - <ul className="list-none p-0 text-sm"> 438 - {suggestions.map((result, index) => { 439 - return ( 440 - <div 441 - className={` 442 - MenuItem 443 - font-bold z-10 py-1 px-3 444 - text-left text-secondary 445 - flex gap-2 446 - ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 447 - hover:bg-border-light hover:text-secondary 448 - outline-none 449 - `} 450 - key={result.did} 451 - onClick={() => { 452 - if (mentionRange) { 453 - props.onSelect(result, mentionRange); 454 - setMentionQuery(null); 455 - setMentionRange(null); 456 - setMentionCoords(null); 457 - } 458 - }} 459 - onMouseDown={(e) => e.preventDefault()} 460 - > 461 - @{result.handle} 462 - </div> 463 - ); 464 - })} 465 - </ul> 466 - </Popover.Content> 467 - </Popover.Portal> 468 - </Popover.Root> 469 - ); 470 - } 471 - 472 - function useMentionSuggestions(query: string | null) { 473 - const [suggestionIndex, setSuggestionIndex] = useState(0); 474 - const [suggestions, setSuggestions] = useState< 475 - { handle: string; did: string }[] 476 - >([]); 477 - 478 - useDebouncedEffect( 479 - async () => { 480 - if (!query) { 481 - setSuggestions([]); 482 - return; 483 - } 484 - 485 - const agent = new Agent("https://public.api.bsky.app"); 486 - const result = await agent.searchActorsTypeahead({ 487 - q: query, 488 - limit: 8, 489 - }); 490 - setSuggestions( 491 - result.data.actors.map((actor) => ({ 492 - handle: actor.handle, 493 - did: actor.did, 494 - })), 495 - ); 496 - }, 497 - 300, 498 - [query], 499 - ); 500 - 501 - useEffect(() => { 502 - if (suggestionIndex > suggestions.length - 1) { 503 - setSuggestionIndex(Math.max(0, suggestions.length - 1)); 504 - } 505 - }, [suggestionIndex, suggestions.length]); 506 - 507 - return { 508 - suggestions, 509 - suggestionIndex, 510 - setSuggestionIndex, 511 - }; 512 - } 513 - 514 /** 515 * Converts a ProseMirror editor state to Bluesky post facets. 516 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 595 596 return features; 597 }
··· 1 "use client"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 5 import { EditorView } from "prosemirror-view"; 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 7 import { baseKeymap } from "prosemirror-commands"; ··· 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 12 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 13 + import { schema } from "components/Blocks/TextBlock/schema"; 14 + import { Mention, MentionAutocomplete } from "components/Mention"; 15 16 // Schema with only links, mentions, and hashtags marks 17 const bskyPostSchema = new Schema({ ··· 127 return tr; 128 }); 129 } 130 export function BlueskyPostEditorProsemirror(props: { 131 + editorStateRef: React.RefObject<EditorState | null>; 132 initialContent?: string; 133 onCharCountChange?: (count: number) => void; 134 }) { 135 const mountRef = useRef<HTMLDivElement | null>(null); 136 const viewRef = useRef<EditorView | null>(null); 137 const [editorState, setEditorState] = useState<EditorState | null>(null); 138 + const [mentionOpen, setMentionOpen] = useState(false); 139 + const [mentionCoords, setMentionCoords] = useState<{ 140 + top: number; 141 + left: number; 142 + } | null>(null); 143 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 144 + 145 + const openMentionAutocomplete = useCallback(() => { 146 + if (!viewRef.current) return; 147 + const view = viewRef.current; 148 + const pos = view.state.selection.from; 149 + setMentionInsertPos(pos); 150 + const coords = view.coordsAtPos(pos - 1); 151 + 152 + // Get coordinates relative to the positioned parent container 153 + const editorEl = view.dom; 154 + const container = editorEl.closest(".relative") as HTMLElement | null; 155 + 156 + if (container) { 157 + const containerRect = container.getBoundingClientRect(); 158 + setMentionCoords({ 159 + top: coords.bottom - containerRect.top, 160 + left: coords.left - containerRect.left, 161 + }); 162 + } else { 163 + setMentionCoords({ 164 + top: coords.bottom, 165 + left: coords.left, 166 + }); 167 + } 168 + setMentionOpen(true); 169 + }, []); 170 171 const handleMentionSelect = useCallback( 172 + (mention: Mention) => { 173 + if (!viewRef.current || mentionInsertPos === null) return; 174 const view = viewRef.current; 175 + const from = mentionInsertPos - 1; 176 + const to = mentionInsertPos; 177 const tr = view.state.tr; 178 179 + // Delete the @ symbol 180 + tr.delete(from, to); 181 182 + if (mention.type === "did") { 183 + // Insert @handle with mention mark 184 + const mentionText = "@" + mention.handle; 185 + tr.insertText(mentionText, from); 186 + tr.addMark( 187 + from, 188 + from + mentionText.length, 189 + bskyPostSchema.marks.mention.create({ did: mention.did }), 190 + ); 191 + tr.insertText(" ", from + mentionText.length); 192 + } else if (mention.type === "publication") { 193 + // Insert publication name as a link 194 + const linkText = mention.name; 195 + tr.insertText(linkText, from); 196 + tr.addMark( 197 + from, 198 + from + linkText.length, 199 + bskyPostSchema.marks.link.create({ href: mention.url }), 200 + ); 201 + tr.insertText(" ", from + linkText.length); 202 + } else if (mention.type === "post") { 203 + // Insert post title as a link 204 + const linkText = mention.title; 205 + tr.insertText(linkText, from); 206 + tr.addMark( 207 + from, 208 + from + linkText.length, 209 + bskyPostSchema.marks.link.create({ href: mention.url }), 210 + ); 211 + tr.insertText(" ", from + linkText.length); 212 + } 213 214 view.dispatch(tr); 215 view.focus(); 216 }, 217 + [mentionInsertPos], 218 ); 219 220 + const handleMentionOpenChange = useCallback((open: boolean) => { 221 + setMentionOpen(open); 222 + if (!open) { 223 + setMentionCoords(null); 224 + setMentionInsertPos(null); 225 + } 226 + }, []); 227 228 useLayoutEffect(() => { 229 if (!mountRef.current) return; 230 231 + // Input rule to trigger mention autocomplete when @ is typed 232 + const mentionInputRule = new InputRule( 233 + /(?:^|\s)@$/, 234 + (state, match, start, end) => { 235 + setTimeout(() => openMentionAutocomplete(), 0); 236 + return null; 237 + }, 238 + ); 239 + 240 const initialState = EditorState.create({ 241 schema: bskyPostSchema, 242 doc: props.initialContent ··· 249 }) 250 : undefined, 251 plugins: [ 252 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 253 keymap({ 254 "Mod-z": undo, 255 "Mod-y": redo, 256 "Shift-Mod-z": redo, 257 }), 258 keymap(baseKeymap), 259 autolink({ ··· 290 view.destroy(); 291 viewRef.current = null; 292 }; 293 + }, [openMentionAutocomplete]); 294 295 return ( 296 <div className="relative w-full h-full group"> 297 + <MentionAutocomplete 298 + open={mentionOpen} 299 + onOpenChange={handleMentionOpenChange} 300 + view={viewRef} 301 + onSelect={handleMentionSelect} 302 + coords={mentionCoords} 303 + placeholder="Search people..." 304 + /> 305 {editorState?.doc.textContent.length === 0 && ( 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 307 Write a post to share your writing! ··· 309 )} 310 <div 311 ref={mountRef} 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 313 style={{ 314 wordWrap: "break-word", 315 overflowWrap: "break-word", ··· 320 ); 321 } 322 323 /** 324 * Converts a ProseMirror editor state to Bluesky post facets. 325 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 404 405 return features; 406 } 407 + 408 + export const addMentionToEditor = ( 409 + mention: Mention, 410 + range: { from: number; to: number }, 411 + view: EditorView, 412 + ) => { 413 + console.log("view", view); 414 + if (!view) return; 415 + const { from, to } = range; 416 + const tr = view.state.tr; 417 + 418 + if (mention.type == "did") { 419 + // Delete the @ and any query text 420 + tr.delete(from, to); 421 + // Insert didMention inline node 422 + const mentionText = "@" + mention.handle; 423 + const didMentionNode = schema.nodes.didMention.create({ 424 + did: mention.did, 425 + text: mentionText, 426 + }); 427 + tr.insert(from, didMentionNode); 428 + } 429 + if (mention.type === "publication" || mention.type === "post") { 430 + // Delete the @ and any query text 431 + tr.delete(from, to); 432 + let name = mention.type == "post" ? mention.title : mention.name; 433 + // Insert atMention inline node 434 + const atMentionNode = schema.nodes.atMention.create({ 435 + atURI: mention.uri, 436 + text: name, 437 + }); 438 + tr.insert(from, atMentionNode); 439 + } 440 + console.log("yo", mention); 441 + 442 + // Add a space after the mention 443 + tr.insertText(" ", from + 1); 444 + 445 + view.dispatch(tr); 446 + view.focus(); 447 + };
+143 -83
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 import { 17 BlueskyPostEditorProsemirror, 18 editorStateToFacetedText, 19 } from "./BskyPostEditorProsemirror"; 20 import { EditorState } from "prosemirror-state"; 21 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 import { PubIcon } from "components/ActionBar/Publications"; 23 ··· 31 record?: PubLeafletPublication.Record; 32 posts_in_pub?: number; 33 entitiesToDelete?: string[]; 34 }; 35 36 export function PublishPost(props: Props) { ··· 38 { state: "default" } | { state: "success"; post_url: string } 39 >({ state: "default" }); 40 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 42 {publishState.state === "default" ? ( 43 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 ) : ( ··· 58 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 } & Props, 60 ) => { 61 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 65 let params = useParams(); 66 let { rep } = useReplicache(); 67 68 async function submit() { 69 if (isLoading) return; 70 setIsLoading(true); ··· 75 leaflet_id: props.leaflet_id, 76 title: props.title, 77 description: props.description, 78 entitiesToDelete: props.entitiesToDelete, 79 }); 80 if (!doc) return; ··· 109 submit(); 110 }} 111 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 113 <PublishingTo 114 publication_uri={props.publication_uri} 115 record={props.record} 116 /> 117 - <hr className="border-border-light my-1" /> 118 - <Radio 119 - checked={shareOption === "quiet"} 120 - onChange={(e) => { 121 - if (e.target === e.currentTarget) { 122 - setShareOption("quiet"); 123 - } 124 - }} 125 - name="share-options" 126 - id="share-quietly" 127 - value="Share Quietly" 128 - > 129 - <div className="flex flex-col"> 130 - <div className="font-bold">Share Quietly</div> 131 - <div className="text-sm text-tertiary font-normal"> 132 - No one will be notified about this post 133 - </div> 134 - </div> 135 - </Radio> 136 - <Radio 137 - checked={shareOption === "bluesky"} 138 - onChange={(e) => { 139 - if (e.target === e.currentTarget) { 140 - setShareOption("bluesky"); 141 - } 142 - }} 143 - name="share-options" 144 - id="share-bsky" 145 - value="Share on Bluesky" 146 - > 147 - <div className="flex flex-col"> 148 - <div className="font-bold">Share on Bluesky</div> 149 - <div className="text-sm text-tertiary font-normal"> 150 - Pub subscribers will be updated via a custom Bluesky feed 151 - </div> 152 - </div> 153 - </Radio> 154 - 155 - <div 156 - className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 157 - > 158 - <div className="opaque-container p-3 rounded-lg!"> 159 - <div className="flex gap-2"> 160 - <img 161 - className="rounded-full w-[42px] h-[42px] shrink-0" 162 - src={props.profile.avatar} 163 - /> 164 - <div className="flex flex-col w-full"> 165 - <div className="flex gap-2 pb-1"> 166 - <p className="font-bold">{props.profile.displayName}</p> 167 - <p className="text-tertiary">@{props.profile.handle}</p> 168 - </div> 169 - <div className="flex flex-col"> 170 - <BlueskyPostEditorProsemirror 171 - editorStateRef={editorStateRef} 172 - onCharCountChange={setCharCount} 173 - /> 174 - </div> 175 - <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> 176 - <div className="flex flex-col p-2"> 177 - <div className="font-bold">{props.title}</div> 178 - <div className="text-tertiary">{props.description}</div> 179 - {props.record && ( 180 - <> 181 - <hr className="border-border-light mt-2 mb-1" /> 182 - <p className="text-xs text-tertiary"> 183 - {props.record?.base_path} 184 - </p> 185 - </> 186 - )} 187 - </div> 188 - </div> 189 - <div className="text-xs text-secondary italic place-self-end pt-2"> 190 - {charCount}/300 191 - </div> 192 - </div> 193 - </div> 194 - </div> 195 </div> 196 <div className="flex justify-between"> 197 <Link 198 className="hover:no-underline! font-bold" ··· 210 </div> 211 </div> 212 </form> 213 </div> 214 ); 215 };
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 + 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 17 import { 18 BlueskyPostEditorProsemirror, 19 editorStateToFacetedText, 20 } from "./BskyPostEditorProsemirror"; 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 25 ··· 33 record?: PubLeafletPublication.Record; 34 posts_in_pub?: number; 35 entitiesToDelete?: string[]; 36 + hasDraft: boolean; 37 }; 38 39 export function PublishPost(props: Props) { ··· 41 { state: "default" } | { state: "success"; post_url: string } 42 >({ state: "default" }); 43 return ( 44 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary"> 45 {publishState.state === "default" ? ( 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 47 ) : ( ··· 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 62 } & Props, 63 ) => { 64 + let editorStateRef = useRef<EditorState | null>(null); 65 + let [charCount, setCharCount] = useState(0); 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 let [isLoading, setIsLoading] = useState(false); 68 let params = useParams(); 69 let { rep } = useReplicache(); 70 71 + // For publications with drafts, use Replicache; otherwise use local state 72 + let replicacheTags = useSubscribe(rep, (tx) => 73 + tx.get<string[]>("publication_tags"), 74 + ); 75 + let [localTags, setLocalTags] = useState<string[]>([]); 76 + 77 + // Use Replicache tags only when we have a draft 78 + const hasDraft = props.hasDraft; 79 + const currentTags = hasDraft 80 + ? Array.isArray(replicacheTags) 81 + ? replicacheTags 82 + : [] 83 + : localTags; 84 + 85 + // Update tags via Replicache mutation or local state depending on context 86 + const handleTagsChange = async (newTags: string[]) => { 87 + if (hasDraft) { 88 + await rep?.mutate.updatePublicationDraft({ 89 + tags: newTags, 90 + }); 91 + } else { 92 + setLocalTags(newTags); 93 + } 94 + }; 95 + 96 async function submit() { 97 if (isLoading) return; 98 setIsLoading(true); ··· 103 leaflet_id: props.leaflet_id, 104 title: props.title, 105 description: props.description, 106 + tags: currentTags, 107 entitiesToDelete: props.entitiesToDelete, 108 }); 109 if (!doc) return; ··· 138 submit(); 139 }} 140 > 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 142 <PublishingTo 143 publication_uri={props.publication_uri} 144 record={props.record} 145 /> 146 + <hr className="border-border" /> 147 + <ShareOptions 148 + setShareOption={setShareOption} 149 + shareOption={shareOption} 150 + charCount={charCount} 151 + setCharCount={setCharCount} 152 + editorStateRef={editorStateRef} 153 + {...props} 154 + /> 155 + <hr className="border-border " /> 156 + <div className="flex flex-col gap-2"> 157 + <h4>Tags</h4> 158 + <TagSelector 159 + selectedTags={currentTags} 160 + setSelectedTags={handleTagsChange} 161 + /> 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 165 <div className="flex justify-between"> 166 <Link 167 className="hover:no-underline! font-bold" ··· 179 </div> 180 </div> 181 </form> 182 + </div> 183 + ); 184 + }; 185 + 186 + const ShareOptions = (props: { 187 + shareOption: "quiet" | "bluesky"; 188 + setShareOption: (option: typeof props.shareOption) => void; 189 + charCount: number; 190 + setCharCount: (c: number) => void; 191 + editorStateRef: React.MutableRefObject<EditorState | null>; 192 + title: string; 193 + profile: ProfileViewDetailed; 194 + description: string; 195 + record?: PubLeafletPublication.Record; 196 + }) => { 197 + return ( 198 + <div className="flex flex-col gap-2"> 199 + <h4>Notifications</h4> 200 + <Radio 201 + checked={props.shareOption === "quiet"} 202 + onChange={(e) => { 203 + if (e.target === e.currentTarget) { 204 + props.setShareOption("quiet"); 205 + } 206 + }} 207 + name="share-options" 208 + id="share-quietly" 209 + value="Share Quietly" 210 + > 211 + <div className="flex flex-col"> 212 + <div className="font-bold">Share Quietly</div> 213 + <div className="text-sm text-tertiary font-normal"> 214 + No one will be notified about this post 215 + </div> 216 + </div> 217 + </Radio> 218 + <Radio 219 + checked={props.shareOption === "bluesky"} 220 + onChange={(e) => { 221 + if (e.target === e.currentTarget) { 222 + props.setShareOption("bluesky"); 223 + } 224 + }} 225 + name="share-options" 226 + id="share-bsky" 227 + value="Share on Bluesky" 228 + > 229 + <div className="flex flex-col"> 230 + <div className="font-bold">Share on Bluesky</div> 231 + <div className="text-sm text-tertiary font-normal"> 232 + Pub subscribers will be updated via a custom Bluesky feed 233 + </div> 234 + </div> 235 + </Radio> 236 + <div 237 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 238 + > 239 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 240 + <div className="flex gap-2"> 241 + <img 242 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 243 + src={props.profile.avatar} 244 + /> 245 + <div className="flex flex-col w-full"> 246 + <div className="flex gap-2 "> 247 + <p className="font-bold">{props.profile.displayName}</p> 248 + <p className="text-tertiary">@{props.profile.handle}</p> 249 + </div> 250 + <div className="flex flex-col"> 251 + <BlueskyPostEditorProsemirror 252 + editorStateRef={props.editorStateRef} 253 + onCharCountChange={props.setCharCount} 254 + /> 255 + </div> 256 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 257 + <div className="flex flex-col p-2"> 258 + <div className="font-bold">{props.title}</div> 259 + <div className="text-tertiary">{props.description}</div> 260 + <hr className="border-border mt-2 mb-1" /> 261 + <p className="text-xs text-tertiary"> 262 + {props.record?.base_path} 263 + </p> 264 + </div> 265 + </div> 266 + <div className="text-xs text-secondary italic place-self-end pt-2"> 267 + {props.charCount}/300 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 273 </div> 274 ); 275 };
+8 -2
app/[leaflet_id]/publish/page.tsx
··· 76 // Get title and description from either source 77 let title = 78 data.leaflets_in_publications[0]?.title || 79 - data.leaflets_to_documents?.title || 80 decodeURIComponent((await props.searchParams).title || ""); 81 let description = 82 data.leaflets_in_publications[0]?.description || 83 - data.leaflets_to_documents?.description || 84 decodeURIComponent((await props.searchParams).description || ""); 85 86 let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); ··· 99 // If parsing fails, just use empty array 100 } 101 102 return ( 103 <ReplicacheProvider 104 rootEntity={rootEntity} ··· 116 record={publication?.record as PubLeafletPublication.Record | undefined} 117 posts_in_pub={publication?.documents_in_publications[0]?.count} 118 entitiesToDelete={entitiesToDelete} 119 /> 120 </ReplicacheProvider> 121 );
··· 76 // Get title and description from either source 77 let title = 78 data.leaflets_in_publications[0]?.title || 79 + data.leaflets_to_documents[0]?.title || 80 decodeURIComponent((await props.searchParams).title || ""); 81 let description = 82 data.leaflets_in_publications[0]?.description || 83 + data.leaflets_to_documents[0]?.description || 84 decodeURIComponent((await props.searchParams).description || ""); 85 86 let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); ··· 99 // If parsing fails, just use empty array 100 } 101 102 + // Check if a draft record exists (either in a publication or standalone) 103 + let hasDraft = 104 + data.leaflets_in_publications.length > 0 || 105 + data.leaflets_to_documents.length > 0; 106 + 107 return ( 108 <ReplicacheProvider 109 rootEntity={rootEntity} ··· 121 record={publication?.record as PubLeafletPublication.Record | undefined} 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 123 entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 125 /> 126 </ReplicacheProvider> 127 );
+145
app/api/pub_icon/route.ts
···
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { NextRequest, NextResponse } from "next/server"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + 8 + const idResolver = new IdResolver(); 9 + 10 + export const runtime = "nodejs"; 11 + 12 + export async function GET(req: NextRequest) { 13 + const searchParams = req.nextUrl.searchParams; 14 + const bgColor = searchParams.get("bg") || "#0000E1"; 15 + const fgColor = searchParams.get("fg") || "#FFFFFF"; 16 + 17 + try { 18 + const at_uri = searchParams.get("at_uri"); 19 + 20 + if (!at_uri) { 21 + return new NextResponse(null, { status: 400 }); 22 + } 23 + 24 + // Parse the AT URI 25 + let uri: AtUri; 26 + try { 27 + uri = new AtUri(at_uri); 28 + } catch (e) { 29 + return new NextResponse(null, { status: 400 }); 30 + } 31 + 32 + let publicationRecord: PubLeafletPublication.Record | null = null; 33 + let publicationUri: string; 34 + 35 + // Check if it's a document or publication 36 + if (uri.collection === "pub.leaflet.document") { 37 + // Query the documents_in_publications table to get the publication 38 + const { data: docInPub } = await supabaseServerClient 39 + .from("documents_in_publications") 40 + .select("publication, publications(record)") 41 + .eq("document", at_uri) 42 + .single(); 43 + 44 + if (!docInPub || !docInPub.publications) { 45 + return new NextResponse(null, { status: 404 }); 46 + } 47 + 48 + publicationUri = docInPub.publication; 49 + publicationRecord = docInPub.publications 50 + .record as PubLeafletPublication.Record; 51 + } else if (uri.collection === "pub.leaflet.publication") { 52 + // Query the publications table directly 53 + const { data: publication } = await supabaseServerClient 54 + .from("publications") 55 + .select("record, uri") 56 + .eq("uri", at_uri) 57 + .single(); 58 + 59 + if (!publication || !publication.record) { 60 + return new NextResponse(null, { status: 404 }); 61 + } 62 + 63 + publicationUri = publication.uri; 64 + publicationRecord = publication.record as PubLeafletPublication.Record; 65 + } else { 66 + // Not a supported collection 67 + return new NextResponse(null, { status: 404 }); 68 + } 69 + 70 + // Check if the publication has an icon 71 + if (!publicationRecord?.icon) { 72 + // Generate a placeholder with the first letter of the publication name 73 + const firstLetter = (publicationRecord?.name || "?") 74 + .slice(0, 1) 75 + .toUpperCase(); 76 + 77 + // Create a simple SVG placeholder with theme colors 78 + const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg"> 79 + <rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/> 80 + <text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text> 81 + </svg>`; 82 + 83 + return new NextResponse(svg, { 84 + headers: { 85 + "Content-Type": "image/svg+xml", 86 + "Cache-Control": 87 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 88 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 89 + }, 90 + }); 91 + } 92 + 93 + // Parse the publication URI to get the DID 94 + const pubUri = new AtUri(publicationUri); 95 + 96 + // Get the CID from the icon blob 97 + const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 98 + "$link" 99 + ]; 100 + 101 + // Fetch the blob from the PDS 102 + const identity = await idResolver.did.resolve(pubUri.host); 103 + const service = identity?.service?.find((f) => f.id === "#atproto_pds"); 104 + if (!service) return new NextResponse(null, { status: 404 }); 105 + 106 + const blobResponse = await fetch( 107 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, 108 + { 109 + headers: { 110 + "Accept-Encoding": "gzip, deflate, br, zstd", 111 + }, 112 + }, 113 + ); 114 + 115 + if (!blobResponse.ok) { 116 + return new NextResponse(null, { status: 404 }); 117 + } 118 + 119 + // Get the image buffer 120 + const imageBuffer = await blobResponse.arrayBuffer(); 121 + 122 + // Resize to 96x96 using Sharp 123 + const resizedImage = await sharp(Buffer.from(imageBuffer)) 124 + .resize(96, 96, { 125 + fit: "cover", 126 + position: "center", 127 + }) 128 + .webp({ quality: 90 }) 129 + .toBuffer(); 130 + 131 + // Return with caching headers 132 + return new NextResponse(resizedImage, { 133 + headers: { 134 + "Content-Type": "image/webp", 135 + // Cache for 1 hour, but serve stale for much longer while revalidating 136 + "Cache-Control": 137 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 138 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 139 + }, 140 + }); 141 + } catch (error) { 142 + console.error("Error fetching publication icon:", error); 143 + return new NextResponse(null, { status: 500 }); 144 + } 145 + }
+1 -1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 9 import { applyUpdate, Doc } from "yjs"; 10 11 export const getFactsFromHomeLeaflets = makeRoute({
··· 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 import * as base64 from "base64-js"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 import { applyUpdate, Doc } from "yjs"; 10 11 export const getFactsFromHomeLeaflets = makeRoute({
+6
app/api/rpc/[command]/pull.ts
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 }[]; 77 let pub_patch = publication_data?.[0] 78 ? [ ··· 85 op: "put", 86 key: "publication_title", 87 value: publication_data[0].title, 88 }, 89 ] 90 : [];
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 + tags: string[]; 77 }[]; 78 let pub_patch = publication_data?.[0] 79 ? [ ··· 86 op: "put", 87 key: "publication_title", 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 94 }, 95 ] 96 : [];
+4
app/api/rpc/[command]/route.ts
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 15 let supabase = createClient<Database>( 16 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 get_leaflet_subdomain_status, 36 get_leaflet_data, 37 get_publication_data, 38 ]; 39 export async function POST( 40 req: Request,
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 + import { search_publication_names } from "./search_publication_names"; 15 + import { search_publication_documents } from "./search_publication_documents"; 16 17 let supabase = createClient<Database>( 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 37 get_leaflet_subdomain_status, 38 get_leaflet_data, 39 get_publication_data, 40 + search_publication_names, 41 + search_publication_documents, 42 ]; 43 export async function POST( 44 req: Request,
+52
app/api/rpc/[command]/search_publication_documents.ts
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { z } from "zod"; 3 + import { makeRoute } from "../lib"; 4 + import type { Env } from "./route"; 5 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 + 7 + export type SearchPublicationDocumentsReturnType = Awaited< 8 + ReturnType<(typeof search_publication_documents)["handler"]> 9 + >; 10 + 11 + export const search_publication_documents = makeRoute({ 12 + route: "search_publication_documents", 13 + input: z.object({ 14 + publication_uri: z.string(), 15 + query: z.string(), 16 + limit: z.number().optional().default(10), 17 + }), 18 + handler: async ( 19 + { publication_uri, query, limit }, 20 + { supabase }: Pick<Env, "supabase">, 21 + ) => { 22 + // Get documents in the publication, filtering by title using JSON operator 23 + // Also join with publications to get the record for URL construction 24 + const { data: documents, error } = await supabase 25 + .from("documents_in_publications") 26 + .select( 27 + "document, documents!inner(uri, data), publications!inner(uri, record)", 28 + ) 29 + .eq("publication", publication_uri) 30 + .ilike("documents.data->>title", `%${query}%`) 31 + .limit(limit); 32 + 33 + if (error) { 34 + throw new Error( 35 + `Failed to search publication documents: ${error.message}`, 36 + ); 37 + } 38 + 39 + const result = documents.map((d) => { 40 + const docUri = new AtUri(d.documents.uri); 41 + const pubUrl = getPublicationURL(d.publications); 42 + 43 + return { 44 + uri: d.documents.uri, 45 + title: (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: `${pubUrl}/${docUri.rkey}`, 47 + }; 48 + }); 49 + 50 + return { result: { documents: result } }; 51 + }, 52 + });
+39
app/api/rpc/[command]/search_publication_names.ts
···
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + 6 + export type SearchPublicationNamesReturnType = Awaited< 7 + ReturnType<(typeof search_publication_names)["handler"]> 8 + >; 9 + 10 + export const search_publication_names = makeRoute({ 11 + route: "search_publication_names", 12 + input: z.object({ 13 + query: z.string(), 14 + limit: z.number().optional().default(10), 15 + }), 16 + handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 17 + // Search publications by name in record (case-insensitive partial match) 18 + const { data: publications, error } = await supabase 19 + .from("publications") 20 + .select("uri, record") 21 + .ilike("record->>name", `%${query}%`) 22 + .limit(limit); 23 + 24 + if (error) { 25 + throw new Error(`Failed to search publications: ${error.message}`); 26 + } 27 + 28 + const result = publications.map((p) => { 29 + const record = p.record as { name?: string }; 30 + return { 31 + uri: p.uri, 32 + name: record.name || "Untitled", 33 + url: getPublicationURL(p), 34 + }; 35 + }); 36 + 37 + return { result: { publications: result } }; 38 + }, 39 + });
+13
app/globals.css
··· 211 212 /* END GLOBAL STYLING */ 213 } 214 button:hover { 215 cursor: pointer; 216 } ··· 289 .selected .selection-highlight { 290 background-color: Highlight; 291 @apply py-[1.5px]; 292 } 293 294 .ProseMirror:focus-within .selection-highlight { ··· 414 outline: none !important; 415 cursor: pointer; 416 background-color: transparent; 417 418 :hover { 419 text-decoration: none !important;
··· 211 212 /* END GLOBAL STYLING */ 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 219 button:hover { 220 cursor: pointer; 221 } ··· 294 .selected .selection-highlight { 295 background-color: Highlight; 296 @apply py-[1.5px]; 297 + } 298 + 299 + /* Underline mention nodes when selected in ProseMirror */ 300 + .ProseMirror .atMention.ProseMirror-selectednode, 301 + .ProseMirror .didMention.ProseMirror-selectednode { 302 + text-decoration: underline; 303 } 304 305 .ProseMirror:focus-within .selection-highlight { ··· 425 outline: none !important; 426 cursor: pointer; 427 background-color: transparent; 428 + display: flex; 429 + gap: 0.5rem; 430 431 :hover { 432 text-decoration: none !important;
+36 -206
app/lish/Subscribe.tsx
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 - import { SpeedyLink } from "components/SpeedyLink"; 27 - 28 - type State = 29 - | { state: "email" } 30 - | { state: "code"; token: string } 31 - | { state: "success" }; 32 - export const SubscribeButton = (props: { 33 - compact?: boolean; 34 - publication: string; 35 - }) => { 36 - let { identity, mutate } = useIdentityData(); 37 - let [emailInputValue, setEmailInputValue] = useState(""); 38 - let [codeInputValue, setCodeInputValue] = useState(""); 39 - let [state, setState] = useState<State>({ state: "email" }); 40 - 41 - if (state.state === "email") { 42 - return ( 43 - <div className="flex gap-2"> 44 - <div className="flex relative w-full max-w-sm"> 45 - <Input 46 - type="email" 47 - className="input-with-border pr-[104px]! py-1! grow w-full" 48 - placeholder={ 49 - props.compact ? "subscribe with email..." : "email here..." 50 - } 51 - disabled={!!identity?.email} 52 - value={identity?.email ? identity.email : emailInputValue} 53 - onChange={(e) => { 54 - setEmailInputValue(e.currentTarget.value); 55 - }} 56 - /> 57 - <ButtonPrimary 58 - compact 59 - className="absolute right-1 top-1 outline-0!" 60 - onClick={async () => { 61 - if (identity?.email) { 62 - await subscribeToPublicationWithEmail(props.publication); 63 - //optimistically could add! 64 - await mutate(); 65 - return; 66 - } 67 - let tokenID = await requestAuthEmailToken(emailInputValue); 68 - setState({ state: "code", token: tokenID }); 69 - }} 70 - > 71 - {props.compact ? ( 72 - <ArrowRightTiny className="w-4 h-6" /> 73 - ) : ( 74 - "Subscribe" 75 - )} 76 - </ButtonPrimary> 77 - </div> 78 - {/* <ShareButton /> */} 79 - </div> 80 - ); 81 - } 82 - if (state.state === "code") { 83 - return ( 84 - <div 85 - className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 86 - style={{ 87 - background: 88 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 89 - }} 90 - > 91 - <div className="flex flex-col leading-snug text-secondary"> 92 - <div>Please enter the code we sent to </div> 93 - <div className="italic font-bold">{emailInputValue}</div> 94 - </div> 95 - 96 - <ConfirmCodeInput 97 - publication={props.publication} 98 - token={state.token} 99 - codeInputValue={codeInputValue} 100 - setCodeInputValue={setCodeInputValue} 101 - setState={setState} 102 - /> 103 - 104 - <button 105 - className="text-accent-contrast text-sm mt-1" 106 - onClick={() => { 107 - setState({ state: "email" }); 108 - }} 109 - > 110 - Re-enter Email 111 - </button> 112 - </div> 113 - ); 114 - } 115 - 116 - if (state.state === "success") { 117 - return ( 118 - <div 119 - className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`} 120 - style={{ 121 - background: 122 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 123 - }} 124 - > 125 - <div className="flex gap-2 leading-snug font-bold italic"> 126 - <div>You're subscribed!</div> 127 - {/* <ShareButton /> */} 128 - </div> 129 - </div> 130 - ); 131 - } 132 - }; 133 - 134 - export const ShareButton = () => { 135 - return ( 136 - <button className="text-accent-contrast"> 137 - <ShareSmall /> 138 - </button> 139 - ); 140 - }; 141 - 142 - const ConfirmCodeInput = (props: { 143 - codeInputValue: string; 144 - token: string; 145 - setCodeInputValue: (value: string) => void; 146 - setState: (state: State) => void; 147 - publication: string; 148 - }) => { 149 - let { mutate } = useIdentityData(); 150 - return ( 151 - <div className="relative w-fit mt-2"> 152 - <Input 153 - type="text" 154 - pattern="[0-9]" 155 - className="input-with-border pr-[88px]! py-1! max-w-[156px]" 156 - placeholder="000000" 157 - value={props.codeInputValue} 158 - onChange={(e) => { 159 - props.setCodeInputValue(e.currentTarget.value); 160 - }} 161 - /> 162 - <ButtonPrimary 163 - compact 164 - className="absolute right-1 top-1 outline-0!" 165 - onClick={async () => { 166 - console.log( 167 - await confirmEmailAuthToken(props.token, props.codeInputValue), 168 - ); 169 - 170 - await subscribeToPublicationWithEmail(props.publication); 171 - //optimistically could add! 172 - await mutate(); 173 - props.setState({ state: "success" }); 174 - return; 175 - }} 176 - > 177 - Confirm 178 - </ButtonPrimary> 179 - </div> 180 - ); 181 - }; 182 183 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 pubName: string; 186 pub_uri: string; 187 base_url: string; ··· 208 } 209 return ( 210 <div className="flex flex-col gap-2 text-center justify-center"> 211 - {props.isPost && ( 212 - <div className="text-sm text-tertiary font-bold"> 213 - Get updates from {props.pubName}! 214 - </div> 215 - )} 216 <div className="flex flex-row gap-2 place-self-center"> 217 <BlueskySubscribeButton 218 pub_uri={props.pub_uri} ··· 231 ); 232 }; 233 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 237 pub_uri: string; 238 subscribers: { identity: string }[]; 239 base_url: string; ··· 248 }); 249 }, null); 250 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 253 > 254 - <div className="font-bold text-tertiary text-sm"> 255 - You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 - {props.isPost && ( 257 - <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 - {props.pubName} 259 - </SpeedyLink> 260 - )} 261 - </div> 262 - <Popover 263 - trigger={<div className="text-accent-contrast text-sm">Manage</div>} 264 - > 265 - <div className="max-w-sm flex flex-col gap-1"> 266 - <h4>Update Options</h4> 267 268 - {!hasFeed && ( 269 - <a 270 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 271 - target="_blank" 272 - className=" place-self-center" 273 - > 274 - <ButtonPrimary fullWidth compact className="!px-4"> 275 - View Bluesky Custom Feed 276 - </ButtonPrimary> 277 - </a> 278 - )} 279 - 280 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 283 target="_blank" 284 - aria-label="Subscribe to RSS" 285 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 288 </ButtonPrimary> 289 </a> 290 291 - <hr className="border-border-light my-1" /> 292 293 - <form action={unsubscribe}> 294 - <button className="font-bold text-accent-contrast w-max place-self-center"> 295 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 296 - </button> 297 - </form> 298 - </div>{" "} 299 - </Popover> 300 - </div> 301 ); 302 }; 303 ··· 430 </Dialog.Root> 431 ); 432 };
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 export const SubscribeWithBluesky = (props: { 28 pubName: string; 29 pub_uri: string; 30 base_url: string; ··· 51 } 52 return ( 53 <div className="flex flex-col gap-2 text-center justify-center"> 54 <div className="flex flex-row gap-2 place-self-center"> 55 <BlueskySubscribeButton 56 pub_uri={props.pub_uri} ··· 69 ); 70 }; 71 72 + export const ManageSubscription = (props: { 73 pub_uri: string; 74 subscribers: { identity: string }[]; 75 base_url: string; ··· 84 }); 85 }, null); 86 return ( 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 91 > 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 94 95 + {!hasFeed && ( 96 <a 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 98 target="_blank" 99 + className=" place-self-center" 100 > 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 103 </ButtonPrimary> 104 </a> 105 + )} 106 107 + <a 108 + href={`${props.base_url}/rss`} 109 + className="flex" 110 + target="_blank" 111 + aria-label="Subscribe to RSS" 112 + > 113 + <ButtonPrimary fullWidth compact> 114 + Get RSS 115 + </ButtonPrimary> 116 + </a> 117 118 + <hr className="border-border-light my-1" /> 119 + 120 + <form action={unsubscribe}> 121 + <button className="font-bold text-accent-contrast w-max place-self-center"> 122 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 123 + </button> 124 + </form> 125 + </div> 126 + </Popover> 127 ); 128 }; 129 ··· 256 </Dialog.Root> 257 ); 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
+30
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 4 type Facet = PubLeafletRichtextFacet.Main; 5 export function BaseTextBlock(props: { ··· 21 let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 22 let isStrikethrough = segment.facet?.find( 23 PubLeafletRichtextFacet.isStrikethrough, 24 ); 25 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); ··· 47 <code key={counter} className={className} id={id?.id}> 48 {renderedText} 49 </code>, 50 ); 51 } else if (link) { 52 children.push(
··· 1 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 5 6 type Facet = PubLeafletRichtextFacet.Main; 7 export function BaseTextBlock(props: { ··· 23 let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 24 let isStrikethrough = segment.facet?.find( 25 PubLeafletRichtextFacet.isStrikethrough, 26 + ); 27 + let isDidMention = segment.facet?.find( 28 + PubLeafletRichtextFacet.isDidMention, 29 + ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 32 ); 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); ··· 55 <code key={counter} className={className} id={id?.id}> 56 {renderedText} 57 </code>, 58 + ); 59 + } else if (isDidMention) { 60 + children.push( 61 + <a 62 + key={counter} 63 + href={didToBlueskyUrl(isDidMention.did)} 64 + className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 + target="_blank" 66 + rel="noopener noreferrer" 67 + > 68 + {renderedText} 69 + </a>, 70 + ); 71 + } else if (isAtMention) { 72 + children.push( 73 + <AtMentionLink 74 + key={counter} 75 + atURI={isAtMention.atURI} 76 + className={className} 77 + > 78 + {renderedText} 79 + </AtMentionLink>, 80 ); 81 } else if (link) { 82 children.push(
+6 -5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 import { PollData } from "./fetchPollData"; 24 import { SharedPageProps } from "./PostPages"; 25 26 export function CanvasPage({ 27 blocks, ··· 206 quotesCount: number | undefined; 207 commentsCount: number | undefined; 208 }) => { 209 return ( 210 - <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 211 <Interactions 212 quotesCount={props.quotesCount || 0} 213 commentsCount={props.commentsCount || 0} 214 - compact 215 showComments={props.preferences.showComments} 216 pageId={props.pageId} 217 /> ··· 219 <> 220 <Separator classname="h-5" /> 221 <Popover 222 - side="left" 223 - align="start" 224 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 225 trigger={<InfoSmall />} 226 > 227 <PostHeader
··· 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 import { PollData } from "./fetchPollData"; 24 import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 26 27 export function CanvasPage({ 28 blocks, ··· 207 quotesCount: number | undefined; 208 commentsCount: number | undefined; 209 }) => { 210 + let isMobile = useIsMobile(); 211 return ( 212 + <div className="flex flex-row gap-3 items-center absolute top-3 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 213 <Interactions 214 quotesCount={props.quotesCount || 0} 215 commentsCount={props.commentsCount || 0} 216 showComments={props.preferences.showComments} 217 pageId={props.pageId} 218 /> ··· 220 <> 221 <Separator classname="h-5" /> 222 <Popover 223 + side="bottom" 224 + align="end" 225 + className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`} 226 trigger={<InfoSmall />} 227 > 228 <PostHeader
+223 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 import { 12 MutableRefObject, 13 RefObject, 14 useEffect, 15 useLayoutEffect, 16 useRef, ··· 36 import { CloseTiny } from "components/Icons/CloseTiny"; 37 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 import { betterIsUrl } from "src/utils/isURL"; 39 40 export function CommentBox(props: { 41 doc_uri: string; ··· 50 commentBox: { quote }, 51 } = useInteractionState(props.doc_uri); 52 let [loading, setLoading] = useState(false); 53 54 - const handleSubmit = async () => { 55 if (loading || !view.current) return; 56 57 setLoading(true); ··· 114 "Mod-y": redo, 115 "Shift-Mod-z": redo, 116 "Ctrl-Enter": () => { 117 - handleSubmit(); 118 return true; 119 }, 120 "Meta-Enter": () => { 121 - handleSubmit(); 122 return true; 123 }, 124 }), ··· 128 shouldAutoLink: () => true, 129 defaultProtocol: "https", 130 }), 131 history(), 132 ], 133 }), 134 ); 135 - let view = useRef<null | EditorView>(null); 136 useLayoutEffect(() => { 137 if (!mountRef.current) return; 138 view.current = new EditorView( ··· 187 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 if (!direct) return; 189 if (node.nodeSize - 2 <= _pos) return; 190 let mark = 191 - node 192 - .nodeAt(_pos - 1) 193 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || 194 - node 195 - .nodeAt(Math.max(_pos - 2, 0)) 196 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 if (mark) { 198 window.open(mark.attrs.href, "_blank"); 199 } 200 }, 201 dispatchTransaction(tr) { ··· 236 <div className="w-full relative group"> 237 <pre 238 ref={mountRef} 239 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 /> 241 <IOSBS view={view} /> 242 </div> 243 <div className="flex justify-between pt-1"> 244 <div className="flex gap-1"> ··· 261 view={view} 262 /> 263 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 265 {loading ? <DotLoader /> : <ShareSmall />} 266 </ButtonPrimary> 267 </div> ··· 328 facets.push(facet); 329 } 330 } 331 332 fullText += text; 333 byteOffset += unicodeString.length;
··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 12 import { 13 MutableRefObject, 14 RefObject, 15 + useCallback, 16 useEffect, 17 useLayoutEffect, 18 useRef, ··· 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { Mention, MentionAutocomplete } from "components/Mention"; 42 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 + 44 + const addMentionToEditor = ( 45 + mention: Mention, 46 + range: { from: number; to: number }, 47 + view: EditorView, 48 + ) => { 49 + if (!view) return; 50 + const { from, to } = range; 51 + const tr = view.state.tr; 52 + 53 + if (mention.type === "did") { 54 + // Delete the @ and any query text 55 + tr.delete(from, to); 56 + // Insert didMention inline node 57 + const mentionText = "@" + mention.handle; 58 + const didMentionNode = multiBlockSchema.nodes.didMention.create({ 59 + did: mention.did, 60 + text: mentionText, 61 + }); 62 + tr.insert(from, didMentionNode); 63 + // Add a space after the mention 64 + tr.insertText(" ", from + 1); 65 + } 66 + if (mention.type === "publication" || mention.type === "post") { 67 + // Delete the @ and any query text 68 + tr.delete(from, to); 69 + let name = mention.type === "post" ? mention.title : mention.name; 70 + // Insert atMention inline node 71 + const atMentionNode = multiBlockSchema.nodes.atMention.create({ 72 + atURI: mention.uri, 73 + text: name, 74 + }); 75 + tr.insert(from, atMentionNode); 76 + // Add a space after the mention 77 + tr.insertText(" ", from + 1); 78 + } 79 + 80 + view.dispatch(tr); 81 + view.focus(); 82 + }; 83 84 export function CommentBox(props: { 85 doc_uri: string; ··· 94 commentBox: { quote }, 95 } = useInteractionState(props.doc_uri); 96 let [loading, setLoading] = useState(false); 97 + let view = useRef<null | EditorView>(null); 98 + 99 + // Mention autocomplete state 100 + const [mentionOpen, setMentionOpen] = useState(false); 101 + const [mentionCoords, setMentionCoords] = useState<{ 102 + top: number; 103 + left: number; 104 + } | null>(null); 105 + // Use a ref for insert position to avoid stale closure issues 106 + const mentionInsertPosRef = useRef<number | null>(null); 107 + 108 + // Use a ref for the callback so input rules can access it 109 + const openMentionAutocompleteRef = useRef<() => void>(() => {}); 110 + openMentionAutocompleteRef.current = () => { 111 + if (!view.current) return; 112 113 + const pos = view.current.state.selection.from; 114 + mentionInsertPosRef.current = pos; 115 + 116 + // Get coordinates for the popup relative to the positioned parent 117 + const coords = view.current.coordsAtPos(pos - 1); 118 + 119 + // Find the relative positioned parent container 120 + const editorEl = view.current.dom; 121 + const container = editorEl.closest(".relative") as HTMLElement | null; 122 + 123 + if (container) { 124 + const containerRect = container.getBoundingClientRect(); 125 + setMentionCoords({ 126 + top: coords.bottom - containerRect.top, 127 + left: coords.left - containerRect.left, 128 + }); 129 + } else { 130 + setMentionCoords({ 131 + top: coords.bottom, 132 + left: coords.left, 133 + }); 134 + } 135 + setMentionOpen(true); 136 + }; 137 + 138 + const handleMentionSelect = useCallback((mention: Mention) => { 139 + if (!view.current || mentionInsertPosRef.current === null) return; 140 + 141 + const from = mentionInsertPosRef.current - 1; 142 + const to = mentionInsertPosRef.current; 143 + 144 + addMentionToEditor(mention, { from, to }, view.current); 145 + view.current.focus(); 146 + }, []); 147 + 148 + const handleMentionOpenChange = useCallback((open: boolean) => { 149 + setMentionOpen(open); 150 + if (!open) { 151 + setMentionCoords(null); 152 + mentionInsertPosRef.current = null; 153 + } 154 + }, []); 155 + 156 + // Use a ref for handleSubmit so keyboard shortcuts can access it 157 + const handleSubmitRef = useRef<() => Promise<void>>(async () => {}); 158 + handleSubmitRef.current = async () => { 159 if (loading || !view.current) return; 160 161 setLoading(true); ··· 218 "Mod-y": redo, 219 "Shift-Mod-z": redo, 220 "Ctrl-Enter": () => { 221 + handleSubmitRef.current(); 222 return true; 223 }, 224 "Meta-Enter": () => { 225 + handleSubmitRef.current(); 226 return true; 227 }, 228 }), ··· 232 shouldAutoLink: () => true, 233 defaultProtocol: "https", 234 }), 235 + // Input rules for @ mentions 236 + inputRules({ 237 + rules: [ 238 + // @ at start of line or after space 239 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 240 + setTimeout(() => openMentionAutocompleteRef.current(), 0); 241 + return null; 242 + }), 243 + ], 244 + }), 245 history(), 246 ], 247 }), 248 ); 249 useLayoutEffect(() => { 250 if (!mountRef.current) return; 251 view.current = new EditorView( ··· 300 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 301 if (!direct) return; 302 if (node.nodeSize - 2 <= _pos) return; 303 + 304 + const nodeAt1 = node.nodeAt(_pos - 1); 305 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 306 + 307 + // Check for link marks 308 let mark = 309 + nodeAt1?.marks.find( 310 + (f) => f.type === multiBlockSchema.marks.link, 311 + ) || 312 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 313 if (mark) { 314 window.open(mark.attrs.href, "_blank"); 315 + return; 316 + } 317 + 318 + // Check for didMention inline nodes 319 + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { 320 + window.open( 321 + didToBlueskyUrl(nodeAt1.attrs.did), 322 + "_blank", 323 + "noopener,noreferrer", 324 + ); 325 + return; 326 + } 327 + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { 328 + window.open( 329 + didToBlueskyUrl(nodeAt2.attrs.did), 330 + "_blank", 331 + "noopener,noreferrer", 332 + ); 333 + return; 334 + } 335 + 336 + // Check for atMention inline nodes (publications/documents) 337 + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { 338 + window.open( 339 + atUriToUrl(nodeAt1.attrs.atURI), 340 + "_blank", 341 + "noopener,noreferrer", 342 + ); 343 + return; 344 + } 345 + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { 346 + window.open( 347 + atUriToUrl(nodeAt2.attrs.atURI), 348 + "_blank", 349 + "noopener,noreferrer", 350 + ); 351 + return; 352 } 353 }, 354 dispatchTransaction(tr) { ··· 389 <div className="w-full relative group"> 390 <pre 391 ref={mountRef} 392 + onFocus={() => { 393 + // Close mention dropdown when editor gains focus (reset stale state) 394 + handleMentionOpenChange(false); 395 + }} 396 + onBlur={(e) => { 397 + // Close mention dropdown when editor loses focus 398 + // But not if focus moved to the mention autocomplete 399 + const relatedTarget = e.relatedTarget as HTMLElement | null; 400 + if (!relatedTarget?.closest(".dropdownMenu")) { 401 + handleMentionOpenChange(false); 402 + } 403 + }} 404 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 405 /> 406 <IOSBS view={view} /> 407 + <MentionAutocomplete 408 + open={mentionOpen} 409 + onOpenChange={handleMentionOpenChange} 410 + view={view} 411 + onSelect={handleMentionSelect} 412 + coords={mentionCoords} 413 + /> 414 </div> 415 <div className="flex justify-between pt-1"> 416 <div className="flex gap-1"> ··· 433 view={view} 434 /> 435 </div> 436 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 437 {loading ? <DotLoader /> : <ShareSmall />} 438 </ButtonPrimary> 439 </div> ··· 500 facets.push(facet); 501 } 502 } 503 + 504 + fullText += text; 505 + byteOffset += unicodeString.length; 506 + } else if (node.type.name === "didMention") { 507 + // Handle DID mention nodes 508 + const text = node.attrs.text || ""; 509 + const unicodeString = new UnicodeString(text); 510 + 511 + facets.push({ 512 + index: { 513 + byteStart: byteOffset, 514 + byteEnd: byteOffset + unicodeString.length, 515 + }, 516 + features: [ 517 + { 518 + $type: "pub.leaflet.richtext.facet#didMention", 519 + did: node.attrs.did, 520 + }, 521 + ], 522 + }); 523 + 524 + fullText += text; 525 + byteOffset += unicodeString.length; 526 + } else if (node.type.name === "atMention") { 527 + // Handle AT-URI mention nodes (publications and documents) 528 + const text = node.attrs.text || ""; 529 + const unicodeString = new UnicodeString(text); 530 + 531 + facets.push({ 532 + index: { 533 + byteStart: byteOffset, 534 + byteEnd: byteOffset + unicodeString.length, 535 + }, 536 + features: [ 537 + { 538 + $type: "pub.leaflet.richtext.facet#atMention", 539 + atURI: node.attrs.atURI, 540 + }, 541 + ], 542 + }); 543 544 fullText += text; 545 byteOffset += unicodeString.length;
+98 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 10 import { Json } from "supabase/database.types"; 11 import { 12 Notification, 13 pingIdentityToUpdateNotification, 14 } from "src/notifications"; 15 import { v7 } from "uuid"; ··· 84 parent_uri: args.comment.replyTo, 85 }, 86 }); 87 // SOMEDAY: move this out the action with inngest or workflows 88 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 90 } 91 92 return { ··· 95 uri: uri.toString(), 96 }; 97 }
··· 10 import { Json } from "supabase/database.types"; 11 import { 12 Notification, 13 + NotificationData, 14 pingIdentityToUpdateNotification, 15 } from "src/notifications"; 16 import { v7 } from "uuid"; ··· 85 parent_uri: args.comment.replyTo, 86 }, 87 }); 88 + } 89 + 90 + // Create mention notifications from comment facets 91 + const mentionNotifications = createCommentMentionNotifications( 92 + args.comment.facets, 93 + uri.toString(), 94 + credentialSession.did!, 95 + ); 96 + notifications.push(...mentionNotifications); 97 + 98 + // Insert all notifications and ping recipients 99 + if (notifications.length > 0) { 100 // SOMEDAY: move this out the action with inngest or workflows 101 await supabaseServerClient.from("notifications").insert(notifications); 102 + 103 + // Ping all unique recipients 104 + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; 105 + await Promise.all( 106 + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), 107 + ); 108 } 109 110 return { ··· 113 uri: uri.toString(), 114 }; 115 } 116 + 117 + /** 118 + * Creates mention notifications from comment facets 119 + * Handles didMention (people) and atMention (publications/documents) 120 + */ 121 + function createCommentMentionNotifications( 122 + facets: PubLeafletRichtextFacet.Main[], 123 + commentUri: string, 124 + commenterDid: string, 125 + ): Notification[] { 126 + const notifications: Notification[] = []; 127 + const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications 128 + 129 + for (const facet of facets) { 130 + for (const feature of facet.features) { 131 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 132 + // DID mention - notify the mentioned person directly 133 + const recipientDid = feature.did; 134 + 135 + // Don't notify yourself 136 + if (recipientDid === commenterDid) continue; 137 + // Avoid duplicate notifications to the same person 138 + if (notifiedRecipients.has(recipientDid)) continue; 139 + notifiedRecipients.add(recipientDid); 140 + 141 + notifications.push({ 142 + id: v7(), 143 + recipient: recipientDid, 144 + data: { 145 + type: "comment_mention", 146 + comment_uri: commentUri, 147 + mention_type: "did", 148 + }, 149 + }); 150 + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { 151 + // AT-URI mention - notify the owner of the publication/document 152 + try { 153 + const mentionedUri = new AtUri(feature.atURI); 154 + const recipientDid = mentionedUri.host; 155 + 156 + // Don't notify yourself 157 + if (recipientDid === commenterDid) continue; 158 + // Avoid duplicate notifications to the same person for the same mentioned item 159 + const dedupeKey = `${recipientDid}:${feature.atURI}`; 160 + if (notifiedRecipients.has(dedupeKey)) continue; 161 + notifiedRecipients.add(dedupeKey); 162 + 163 + if (mentionedUri.collection === "pub.leaflet.publication") { 164 + notifications.push({ 165 + id: v7(), 166 + recipient: recipientDid, 167 + data: { 168 + type: "comment_mention", 169 + comment_uri: commentUri, 170 + mention_type: "publication", 171 + mentioned_uri: feature.atURI, 172 + }, 173 + }); 174 + } else if (mentionedUri.collection === "pub.leaflet.document") { 175 + notifications.push({ 176 + id: v7(), 177 + recipient: recipientDid, 178 + data: { 179 + type: "comment_mention", 180 + comment_uri: commentUri, 181 + mention_type: "document", 182 + mentioned_uri: feature.atURI, 183 + }, 184 + }); 185 + } 186 + } catch (error) { 187 + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); 188 + } 189 + } 190 + } 191 + } 192 + 193 + return notifications; 194 + }
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 14 import { prefetchQuotesData } from "./Quotes"; 15 16 export type InteractionState = { 17 drawerOpen: undefined | boolean; ··· 99 export const Interactions = (props: { 100 quotesCount: number; 101 commentsCount: number; 102 - compact?: boolean; 103 className?: string; 104 showComments?: boolean; 105 pageId?: string; 106 }) => { 107 const data = useContext(PostPageContext); 108 const document_uri = data?.uri; 109 if (!document_uri) 110 throw new Error("document_uri not available in PostPageContext"); 111 ··· 117 } 118 }; 119 120 return ( 121 - <div 122 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`} 123 - > 124 - <button 125 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 126 - onClick={() => { 127 - if (!drawerOpen || drawer !== "quotes") 128 - openInteractionDrawer("quotes", document_uri, props.pageId); 129 - else setInteractionState(document_uri, { drawerOpen: false }); 130 - }} 131 - onMouseEnter={handleQuotePrefetch} 132 - onTouchStart={handleQuotePrefetch} 133 - aria-label="Post quotes" 134 - > 135 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 136 - {!props.compact && ( 137 - <span 138 - aria-hidden 139 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 140 - )} 141 - </button> 142 {props.showComments === false ? null : ( 143 <button 144 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 145 onClick={() => { 146 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 }} 150 aria-label="Post comments" 151 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 158 </button> 159 )} 160 </div> 161 ); 162 }; 163 164 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 if (!document) return; 166 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 ).length; 200 }
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { TagTiny } from "components/Icons/TagTiny"; 13 + import { Tag } from "components/Tags"; 14 + import { Popover } from "components/Popover"; 15 import { PostPageData } from "../getPostPageData"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 17 import { prefetchQuotesData } from "./Quotes"; 18 + import { useIdentityData } from "components/IdentityProvider"; 19 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 20 + import { EditTiny } from "components/Icons/EditTiny"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 22 23 export type InteractionState = { 24 drawerOpen: undefined | boolean; ··· 106 export const Interactions = (props: { 107 quotesCount: number; 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 pageId?: string; 112 }) => { 113 const data = useContext(PostPageContext); 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 116 if (!document_uri) 117 throw new Error("document_uri not available in PostPageContext"); 118 ··· 124 } 125 }; 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 130 return ( 131 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 + 134 + {props.quotesCount > 0 && ( 135 + <button 136 + className="flex w-fit gap-2 items-center" 137 + onClick={() => { 138 + if (!drawerOpen || drawer !== "quotes") 139 + openInteractionDrawer("quotes", document_uri, props.pageId); 140 + else setInteractionState(document_uri, { drawerOpen: false }); 141 + }} 142 + onMouseEnter={handleQuotePrefetch} 143 + onTouchStart={handleQuotePrefetch} 144 + aria-label="Post quotes" 145 + > 146 + <QuoteTiny aria-hidden /> {props.quotesCount} 147 + </button> 148 + )} 149 {props.showComments === false ? null : ( 150 <button 151 + className="flex gap-2 items-center w-fit" 152 onClick={() => { 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 }} 157 aria-label="Post comments" 158 > 159 + <CommentTiny aria-hidden /> {props.commentsCount} 160 </button> 161 )} 162 </div> 163 ); 164 }; 165 166 + export const ExpandedInteractions = (props: { 167 + quotesCount: number; 168 + commentsCount: number; 169 + className?: string; 170 + showComments?: boolean; 171 + pageId?: string; 172 + }) => { 173 + const data = useContext(PostPageContext); 174 + let { identity } = useIdentityData(); 175 + 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + 180 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 181 + 182 + const handleQuotePrefetch = () => { 183 + if (data?.quotesAndMentions) { 184 + prefetchQuotesData(data.quotesAndMentions); 185 + } 186 + }; 187 + let publication = data?.documents_in_publications[0]?.publications; 188 + 189 + const tags = (data?.data as any)?.tags as string[] | undefined; 190 + const tagCount = tags?.length || 0; 191 + 192 + let subscribed = 193 + identity?.atp_did && 194 + publication?.publication_subscriptions && 195 + publication?.publication_subscriptions.find( 196 + (s) => s.identity === identity.atp_did, 197 + ); 198 + 199 + let isAuthor = 200 + identity && 201 + identity.atp_did === 202 + data.documents_in_publications[0]?.publications?.identity_did && 203 + data.leaflets_in_publications[0]; 204 + 205 + return ( 206 + <div 207 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 208 + > 209 + {!subscribed && !isAuthor && publication && publication.record && ( 210 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 211 + <div className="flex flex-col py-4"> 212 + <div className="leading-snug flex flex-col pb-2 text-sm"> 213 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 214 + to get updates in Reader, RSS, or via Bluesky Feed 215 + </div> 216 + <SubscribeWithBluesky 217 + pubName={publication.name} 218 + pub_uri={publication.uri} 219 + base_url={ 220 + (publication.record as PubLeafletPublication.Record) 221 + .base_path || "" 222 + } 223 + subscribers={publication?.publication_subscriptions} 224 + /> 225 + </div> 226 + </div> 227 + )} 228 + {tagCount > 0 && ( 229 + <> 230 + <hr className="border-border-light mb-3" /> 231 + 232 + <TagList tags={tags} className="mb-3" /> 233 + </> 234 + )} 235 + <hr className="border-border-light mb-3 " /> 236 + <div className="flex gap-2 justify-between"> 237 + <div className="flex gap-2"> 238 + {props.quotesCount > 0 && ( 239 + <button 240 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 241 + onClick={() => { 242 + if (!drawerOpen || drawer !== "quotes") 243 + openInteractionDrawer("quotes", document_uri, props.pageId); 244 + else setInteractionState(document_uri, { drawerOpen: false }); 245 + }} 246 + onMouseEnter={handleQuotePrefetch} 247 + onTouchStart={handleQuotePrefetch} 248 + aria-label="Post quotes" 249 + > 250 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 + <span 252 + aria-hidden 253 + >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 254 + </button> 255 + )} 256 + {props.showComments === false ? null : ( 257 + <button 258 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + onClick={() => { 260 + if ( 261 + !drawerOpen || 262 + drawer !== "comments" || 263 + pageId !== props.pageId 264 + ) 265 + openInteractionDrawer("comments", document_uri, props.pageId); 266 + else setInteractionState(document_uri, { drawerOpen: false }); 267 + }} 268 + aria-label="Post comments" 269 + > 270 + <CommentTiny aria-hidden />{" "} 271 + {props.commentsCount > 0 ? ( 272 + <span aria-hidden> 273 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 274 + </span> 275 + ) : ( 276 + "Comment" 277 + )} 278 + </button> 279 + )} 280 + </div> 281 + <EditButton document={data} /> 282 + {subscribed && publication && ( 283 + <ManageSubscription 284 + base_url={getPublicationURL(publication)} 285 + pub_uri={publication.uri} 286 + subscribers={publication.publication_subscriptions} 287 + /> 288 + )} 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + const TagPopover = (props: { 295 + tagCount: number; 296 + tags: string[] | undefined; 297 + }) => { 298 + return ( 299 + <Popover 300 + className="p-2! max-w-xs" 301 + trigger={ 302 + <div className="tags flex gap-1 items-center "> 303 + <TagTiny /> {props.tagCount} 304 + </div> 305 + } 306 + > 307 + <TagList tags={props.tags} className="text-secondary!" /> 308 + </Popover> 309 + ); 310 + }; 311 + 312 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 313 + if (!props.tags) return; 314 + return ( 315 + <div className="flex gap-1 flex-wrap"> 316 + {props.tags.map((tag, index) => ( 317 + <Tag name={tag} key={index} className={props.className} /> 318 + ))} 319 + </div> 320 + ); 321 + }; 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 323 if (!document) return; 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 357 ).length; 358 } 359 + 360 + const EditButton = (props: { document: PostPageData }) => { 361 + let { identity } = useIdentityData(); 362 + if (!props.document) return; 363 + if ( 364 + identity && 365 + identity.atp_did === 366 + props.document.documents_in_publications[0]?.publications?.identity_did && 367 + props.document.leaflets_in_publications[0] 368 + ) 369 + return ( 370 + <a 371 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 372 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 373 + > 374 + <EditTiny /> Edit Post 375 + </a> 376 + ); 377 + return; 378 + };
+4 -40
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 getCommentCount, 15 getQuoteCount, 16 Interactions, ··· 47 fullPageScroll, 48 hasPageBackground, 49 } = props; 50 - let { identity } = useIdentityData(); 51 let drawer = useDrawerOpen(document_uri); 52 53 if (!document) return null; ··· 84 did={did} 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 /> 87 - <Interactions 88 pageId={pageId} 89 showComments={preferences.showComments} 90 commentsCount={getCommentCount(document, pageId) || 0} 91 quotesCount={getQuoteCount(document, pageId) || 0} 92 /> 93 - {!isSubpage && ( 94 - <> 95 - <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 96 - <div className="sm:px-4 px-3"> 97 - {identity && 98 - identity.atp_did === 99 - document.documents_in_publications[0]?.publications 100 - ?.identity_did && 101 - document.leaflets_in_publications[0] ? ( 102 - <a 103 - href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 104 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 105 - > 106 - <EditTiny /> Edit Post 107 - </a> 108 - ) : ( 109 - document.documents_in_publications[0]?.publications && ( 110 - <SubscribeWithBluesky 111 - isPost 112 - base_url={getPublicationURL( 113 - document.documents_in_publications[0].publications, 114 - )} 115 - pub_uri={ 116 - document.documents_in_publications[0].publications.uri 117 - } 118 - subscribers={ 119 - document.documents_in_publications[0].publications 120 - .publication_subscriptions 121 - } 122 - pubName={ 123 - document.documents_in_publications[0].publications.name 124 - } 125 - /> 126 - ) 127 - )} 128 - </div> 129 - </> 130 - )} 131 </PageWrapper> 132 </> 133 );
··· 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 + ExpandedInteractions, 15 getCommentCount, 16 getQuoteCount, 17 Interactions, ··· 48 fullPageScroll, 49 hasPageBackground, 50 } = props; 51 let drawer = useDrawerOpen(document_uri); 52 53 if (!document) return null; ··· 84 did={did} 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 /> 87 + 88 + <ExpandedInteractions 89 pageId={pageId} 90 showComments={preferences.showComments} 91 commentsCount={getCommentCount(document, pageId) || 0} 92 quotesCount={getQuoteCount(document, pageId) || 0} 93 /> 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 95 </PageWrapper> 96 </> 97 );
+11 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 return ( 60 <div 61 //The postContent class is important for QuoteHandler 62 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`} 63 > 64 {blocks.map((b, index) => { 65 return ( ··· 293 } 294 case PubLeafletBlocksImage.isMain(b.block): { 295 return ( 296 - <div className={`relative flex ${alignment}`} {...blockProps}> 297 <img 298 alt={b.block.alt} 299 height={b.block.aspectRatio?.height} ··· 321 return ( 322 // 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. 323 <blockquote 324 - className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 325 {...blockProps} 326 > 327 <TextBlock ··· 336 } 337 case PubLeafletBlocksText.isMain(b.block): 338 return ( 339 - <p className={` ${className}`} {...blockProps}> 340 <TextBlock 341 facets={b.block.facets} 342 plaintext={b.block.plaintext} ··· 349 case PubLeafletBlocksHeader.isMain(b.block): { 350 if (b.block.level === 1) 351 return ( 352 - <h2 className={`${className}`} {...blockProps}> 353 <TextBlock 354 {...b.block} 355 index={index} ··· 360 ); 361 if (b.block.level === 2) 362 return ( 363 - <h3 className={`${className}`} {...blockProps}> 364 <TextBlock 365 {...b.block} 366 index={index} ··· 371 ); 372 if (b.block.level === 3) 373 return ( 374 - <h4 className={`${className}`} {...blockProps}> 375 <TextBlock 376 {...b.block} 377 index={index} ··· 383 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 384 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 385 return ( 386 - <h6 className={`${className}`} {...blockProps}> 387 <TextBlock 388 {...b.block} 389 index={index}
··· 59 return ( 60 <div 61 //The postContent class is important for QuoteHandler 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 > 64 {blocks.map((b, index) => { 65 return ( ··· 293 } 294 case PubLeafletBlocksImage.isMain(b.block): { 295 return ( 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 300 <img 301 alt={b.block.alt} 302 height={b.block.aspectRatio?.height} ··· 324 return ( 325 // 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. 326 <blockquote 327 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 328 {...blockProps} 329 > 330 <TextBlock ··· 339 } 340 case PubLeafletBlocksText.isMain(b.block): 341 return ( 342 + <p className={`textBlock ${className}`} {...blockProps}> 343 <TextBlock 344 facets={b.block.facets} 345 plaintext={b.block.plaintext} ··· 352 case PubLeafletBlocksHeader.isMain(b.block): { 353 if (b.block.level === 1) 354 return ( 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 356 <TextBlock 357 {...b.block} 358 index={index} ··· 363 ); 364 if (b.block.level === 2) 365 return ( 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 367 <TextBlock 368 {...b.block} 369 index={index} ··· 374 ); 375 if (b.block.level === 3) 376 return ( 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 378 <TextBlock 379 {...b.block} 380 index={index} ··· 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 388 return ( 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 390 <TextBlock 391 {...b.block} 392 index={index}
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
··· 1 - "use client"; 2 - 3 - import { Media } from "components/Media"; 4 - import { 5 - Interactions, 6 - useInteractionState, 7 - } from "../Interactions/Interactions"; 8 - import { useState, useEffect } from "react"; 9 - import { Json } from "supabase/database.types"; 10 - 11 - // export const CollapsedPostHeader = (props: { 12 - // title: string; 13 - // pubIcon?: string; 14 - // quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 - // }) => { 16 - // let [headerVisible, setHeaderVisible] = useState(false); 17 - // let { drawerOpen: open } = useInteractionState(); 18 - 19 - // useEffect(() => { 20 - // let post = window.document.getElementById("post-page"); 21 - 22 - // function handleScroll() { 23 - // let postHeader = window.document 24 - // .getElementById("post-header") 25 - // ?.getBoundingClientRect(); 26 - // if (postHeader && postHeader.bottom <= 0) { 27 - // setHeaderVisible(true); 28 - // } else { 29 - // setHeaderVisible(false); 30 - // } 31 - // } 32 - // post?.addEventListener("scroll", handleScroll); 33 - // return () => { 34 - // post?.removeEventListener("scroll", handleScroll); 35 - // }; 36 - // }, []); 37 - // if (!headerVisible) return; 38 - // if (open) return; 39 - // return ( 40 - // <Media 41 - // mobile 42 - // className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 - // > 44 - // <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 - // <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 - // {props.title} 47 - // </div> 48 - // <div className="flex gap-2 "> 49 - // <Interactions compact quotes={props.quotes.length} /> 50 - // <div 51 - // style={{ 52 - // backgroundRepeat: "no-repeat", 53 - // backgroundPosition: "center", 54 - // backgroundSize: "cover", 55 - // backgroundImage: `url(${props.pubIcon})`, 56 - // }} 57 - // className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 - // /> 59 - // </div> 60 - // </div> 61 - // </Media> 62 - // ); 63 - // };
···
+62 -32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 20 export function PostHeader(props: { 21 data: PostPageData; ··· 40 41 if (!document?.data) return; 42 return ( 43 - <div 44 - className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" 45 - id="post-header" 46 - > 47 - <div className="pubHeader flex flex-col pb-5"> 48 - <div className="flex justify-between w-full"> 49 {pub && ( 50 <SpeedyLink 51 className="font-bold hover:no-underline text-accent-contrast" ··· 65 <EditTiny className="shrink-0" /> 66 </a> 67 )} 68 - </div> 69 - <h2 className="">{record.title}</h2> 70 - {record.description ? ( 71 - <p className="italic text-secondary">{record.description}</p> 72 - ) : null} 73 - 74 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 75 - {profile ? ( 76 - <> 77 - <a 78 - className="text-tertiary" 79 - href={`https://bsky.app/profile/${profile.handle}`} 80 - > 81 - by {profile.displayName || profile.handle} 82 - </a> 83 - </> 84 - ) : null} 85 - {record.publishedAt ? ( 86 - <> 87 - |<p>{formattedDate}</p> 88 - </> 89 - ) : null} 90 - |{" "} 91 <Interactions 92 showComments={props.preferences.showComments} 93 - compact 94 quotesCount={getQuoteCount(document) || 0} 95 commentsCount={getCommentCount(document) || 0} 96 /> 97 - </div> 98 </div> 99 </div> 100 ); 101 - }
··· 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 + import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 + import { Separator } from "components/Layout"; 21 22 export function PostHeader(props: { 23 data: PostPageData; ··· 42 43 if (!document?.data) return; 44 return ( 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 48 {pub && ( 49 <SpeedyLink 50 className="font-bold hover:no-underline text-accent-contrast" ··· 64 <EditTiny className="shrink-0" /> 65 </a> 66 )} 67 + </> 68 + } 69 + postTitle={record.title} 70 + postDescription={record.description} 71 + postInfo={ 72 + <> 73 + <div className="flex flex-row gap-2 items-center"> 74 + {profile ? ( 75 + <> 76 + <a 77 + className="text-tertiary" 78 + href={`https://bsky.app/profile/${profile.handle}`} 79 + > 80 + {profile.displayName || profile.handle} 81 + </a> 82 + </> 83 + ) : null} 84 + {record.publishedAt ? ( 85 + <> 86 + <Separator classname="h-4!" /> 87 + <p>{formattedDate}</p> 88 + </> 89 + ) : null} 90 + </div> 91 <Interactions 92 showComments={props.preferences.showComments} 93 quotesCount={getQuoteCount(document) || 0} 94 commentsCount={getCommentCount(document) || 0} 95 /> 96 + </> 97 + } 98 + /> 99 + ); 100 + } 101 + 102 + export const PostHeaderLayout = (props: { 103 + pubLink: React.ReactNode; 104 + postTitle: React.ReactNode | undefined; 105 + postDescription: React.ReactNode | undefined; 106 + postInfo: React.ReactNode; 107 + }) => { 108 + return ( 109 + <div 110 + className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 + id="post-header" 112 + > 113 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 114 + {props.pubLink} 115 + </div> 116 + <h2 117 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 118 + > 119 + {props.postTitle ? props.postTitle : "Untitled"} 120 + </h2> 121 + {props.postDescription ? ( 122 + <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + {props.postDescription} 124 + </p> 125 + ) : null} 126 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 + {props.postInfo} 128 </div> 129 </div> 130 ); 131 + };
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 25 26 return { 27 icons: { 28 other: { 29 rel: "alternate", 30 url: document.uri,
··· 25 26 return { 27 icons: { 28 + icon: { 29 + url: 30 + process.env.NODE_ENV === "development" 31 + ? `/lish/${did}/${params.publication}/icon` 32 + : "/icon", 33 + sizes: "32x32", 34 + type: "image/png", 35 + }, 36 other: { 37 rel: "alternate", 38 url: document.uri,
+20 -33
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 4 import React from "react"; 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 - import { EmptyState } from "components/EmptyState"; 8 9 export function DraftList(props: { 10 searchValue: string; ··· 13 let { data: pub_data } = usePublicationData(); 14 if (!pub_data?.publication) return null; 15 let { leaflets_in_publications, ...publication } = pub_data.publication; 16 - let filteredLeaflets = leaflets_in_publications 17 - .filter((l) => !l.documents) 18 - .filter((l) => !l.archived) 19 - .map((l) => { 20 - return { 21 - archived: l.archived, 22 - added_at: "", 23 - token: { 24 - ...l.permission_tokens!, 25 - leaflets_in_publications: [ 26 - { 27 - ...l, 28 - publications: { 29 - ...publication, 30 - }, 31 - }, 32 - ], 33 - }, 34 - }; 35 - }); 36 - 37 - 38 - 39 - if (!filteredLeaflets || filteredLeaflets.length === 0) 40 - return ( 41 - <EmptyState> 42 - No drafts yet! 43 - <NewDraftSecondaryButton publication={pub_data?.publication?.uri} /> 44 - </EmptyState> 45 - ); 46 - 47 return ( 48 <div className="flex flex-col gap-4"> 49 <NewDraftSecondaryButton ··· 56 showPreview={false} 57 defaultDisplay="list" 58 cardBorderHidden={!props.showPageBackground} 59 - leaflets={filteredLeaflets} 60 initialFacts={pub_data.leaflet_data.facts || {}} 61 titles={{ 62 ...leaflets_in_publications.reduce(
··· 4 import React from "react"; 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 8 export function DraftList(props: { 9 searchValue: string; ··· 12 let { data: pub_data } = usePublicationData(); 13 if (!pub_data?.publication) return null; 14 let { leaflets_in_publications, ...publication } = pub_data.publication; 15 return ( 16 <div className="flex flex-col gap-4"> 17 <NewDraftSecondaryButton ··· 24 showPreview={false} 25 defaultDisplay="list" 26 cardBorderHidden={!props.showPageBackground} 27 + leaflets={leaflets_in_publications 28 + .filter((l) => !l.documents) 29 + .filter((l) => !l.archived) 30 + .map((l) => { 31 + return { 32 + archived: l.archived, 33 + added_at: "", 34 + token: { 35 + ...l.permission_tokens!, 36 + leaflets_in_publications: [ 37 + { 38 + ...l, 39 + publications: { 40 + ...publication, 41 + }, 42 + }, 43 + ], 44 + }, 45 + }; 46 + })} 47 initialFacts={pub_data.leaflet_data.facts || {}} 48 titles={{ 49 ...leaflets_in_publications.reduce(
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 32 <ButtonSecondary 33 fullWidth={props.fullWidth} 34 id="new-leaflet-button" 35 - className="mx-auto" 36 onClick={async () => { 37 let newLeaflet = await createPublicationDraft(props.publication); 38 router.push(`/${newLeaflet}`);
··· 32 <ButtonSecondary 33 fullWidth={props.fullWidth} 34 id="new-leaflet-button" 35 onClick={async () => { 36 let newLeaflet = await createPublicationDraft(props.publication); 37 router.push(`/${newLeaflet}`);
+25 -31
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 23 - import { EmptyState } from "components/EmptyState"; 24 25 export function PublishedPostsList(props: { 26 searchValue: string; ··· 29 let { data } = usePublicationData(); 30 let params = useParams(); 31 let { publication } = data!; 32 if (!publication) return null; 33 if (publication.documents_in_publications.length === 0) 34 - return <EmptyState>Nothing's been published yet...</EmptyState>; 35 return ( 36 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 37 {publication.documents_in_publications ··· 52 (l) => doc.documents && l.doc === doc.documents.uri, 53 ); 54 let uri = new AtUri(doc.documents.uri); 55 - let record = doc.documents.data as PubLeafletDocument.Record; 56 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 57 let comments = doc.documents.comments_on_documents[0]?.count || 0; 58 59 let postLink = data?.publication 60 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 78 href={`${getPublicationURL(publication)}/${uri.rkey}`} 79 > 80 <h3 className="text-primary grow leading-snug"> 81 - {record.title} 82 </h3> 83 </a> 84 <div className="flex justify-start align-top flex-row gap-1"> ··· 107 : null, 108 }, 109 ], 110 - leaflets_to_documents: null, 111 blocked_by_admin: null, 112 custom_domain_routes: [], 113 }} ··· 119 </div> 120 </div> 121 122 - {record.description ? ( 123 <p className="italic text-secondary"> 124 - {record.description} 125 </p> 126 ) : null} 127 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 128 - {record.publishedAt ? ( 129 - <PublishedDate dateString={record.publishedAt} /> 130 ) : null} 131 - {(comments > 0 || quotes > 0) && record.publishedAt 132 - ? " | " 133 - : ""} 134 - {quotes > 0 && ( 135 - <SpeedyLink 136 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 137 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 138 - > 139 - <QuoteTiny /> {quotes} 140 - </SpeedyLink> 141 - )} 142 - {comments > 0 && quotes > 0 ? " " : ""} 143 - {comments > 0 && ( 144 - <SpeedyLink 145 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 146 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 147 - > 148 - <CommentTiny /> {comments} 149 - </SpeedyLink> 150 - )} 151 </div> 152 </div> 153 </div>
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 23 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 24 25 export function PublishedPostsList(props: { 26 searchValue: string; ··· 29 let { data } = usePublicationData(); 30 let params = useParams(); 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 34 if (!publication) return null; 35 if (publication.documents_in_publications.length === 0) 36 + return ( 37 + <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 38 + Nothing's been published yet... 39 + </div> 40 + ); 41 return ( 42 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 43 {publication.documents_in_publications ··· 58 (l) => doc.documents && l.doc === doc.documents.uri, 59 ); 60 let uri = new AtUri(doc.documents.uri); 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 65 66 let postLink = data?.publication 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 > 87 <h3 className="text-primary grow leading-snug"> 88 + {postRecord.title} 89 </h3> 90 </a> 91 <div className="flex justify-start align-top flex-row gap-1"> ··· 114 : null, 115 }, 116 ], 117 + leaflets_to_documents: [], 118 blocked_by_admin: null, 119 custom_domain_routes: [], 120 }} ··· 126 </div> 127 </div> 128 129 + {postRecord.description ? ( 130 <p className="italic text-secondary"> 131 + {postRecord.description} 132 </p> 133 ) : null} 134 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 + {postRecord.publishedAt ? ( 136 + <PublishedDate dateString={postRecord.publishedAt} /> 137 ) : null} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 145 </div> 146 </div> 147 </div>
+67
app/lish/[did]/[publication]/icon/route.ts
···
··· 1 + import { NextRequest } from "next/server"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + import { redirect } from "next/navigation"; 8 + 9 + let idResolver = new IdResolver(); 10 + 11 + export const dynamic = "force-dynamic"; 12 + 13 + export async function GET( 14 + request: NextRequest, 15 + props: { params: Promise<{ did: string; publication: string }> }, 16 + ) { 17 + console.log("are we getting here?"); 18 + const params = await props.params; 19 + try { 20 + let did = decodeURIComponent(params.did); 21 + let uri; 22 + if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 23 + uri = AtUri.make( 24 + did, 25 + "pub.leaflet.publication", 26 + params.publication, 27 + ).toString(); 28 + } 29 + let { data: publication } = await supabaseServerClient 30 + .from("publications") 31 + .select( 32 + `*, 33 + publication_subscriptions(*), 34 + documents_in_publications(documents(*)) 35 + `, 36 + ) 37 + .eq("identity_did", did) 38 + .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 39 + .single(); 40 + 41 + let record = publication?.record as PubLeafletPublication.Record | null; 42 + if (!record?.icon) return redirect("/icon.png"); 43 + 44 + let identity = await idResolver.did.resolve(did); 45 + let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 46 + if (!service) return redirect("/icon.png"); 47 + let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 48 + const response = await fetch( 49 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 50 + ); 51 + let blob = await response.blob(); 52 + let resizedImage = await sharp(await blob.arrayBuffer()) 53 + .resize({ width: 32, height: 32 }) 54 + .toBuffer(); 55 + return new Response(new Uint8Array(resizedImage), { 56 + headers: { 57 + "Content-Type": "image/png", 58 + "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", 59 + "Cache-Control": 60 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 61 + }, 62 + }); 63 + } catch (e) { 64 + console.log(e); 65 + return redirect("/icon.png"); 66 + } 67 + }
-68
app/lish/[did]/[publication]/icon.ts
··· 1 - import { NextRequest } from "next/server"; 2 - import { IdResolver } from "@atproto/identity"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 - import { supabaseServerClient } from "supabase/serverClient"; 6 - import sharp from "sharp"; 7 - import { redirect } from "next/navigation"; 8 - 9 - let idResolver = new IdResolver(); 10 - 11 - export const size = { 12 - width: 32, 13 - height: 32, 14 - }; 15 - 16 - export const contentType = "image/png"; 17 - export default async function Icon(props: { 18 - params: Promise<{ did: string; publication: string }>; 19 - }) { 20 - const params = await props.params; 21 - try { 22 - let did = decodeURIComponent(params.did); 23 - let uri; 24 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 25 - uri = AtUri.make( 26 - did, 27 - "pub.leaflet.publication", 28 - params.publication, 29 - ).toString(); 30 - } 31 - let { data: publication } = await supabaseServerClient 32 - .from("publications") 33 - .select( 34 - `*, 35 - publication_subscriptions(*), 36 - documents_in_publications(documents(*)) 37 - `, 38 - ) 39 - .eq("identity_did", did) 40 - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 41 - .single(); 42 - 43 - let record = publication?.record as PubLeafletPublication.Record | null; 44 - if (!record?.icon) return redirect("/icon.png"); 45 - 46 - let identity = await idResolver.did.resolve(did); 47 - let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 48 - if (!service) return null; 49 - let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 50 - const response = await fetch( 51 - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 52 - ); 53 - let blob = await response.blob(); 54 - let resizedImage = await sharp(await blob.arrayBuffer()) 55 - .resize({ width: 32, height: 32 }) 56 - .toBuffer(); 57 - return new Response(new Uint8Array(resizedImage), { 58 - headers: { 59 - "Content-Type": "image/png", 60 - "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", 61 - "Cache-Control": 62 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 63 - }, 64 - }); 65 - } catch (e) { 66 - return redirect("/icon.png"); 67 - } 68 - }
···
+8
app/lish/[did]/[publication]/layout.tsx
··· 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 icons: { 50 other: { 51 rel: "alternate", 52 url: publication.uri,
··· 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 icons: { 50 + icon: { 51 + url: 52 + process.env.NODE_ENV === "development" 53 + ? `/lish/${did}/${publication_name}/icon` 54 + : "/icon", 55 + sizes: "32x32", 56 + type: "image/png", 57 + }, 58 other: { 59 rel: "alternate", 60 url: publication.uri,
+9 -17
app/lish/[did]/[publication]/page.tsx
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 import { LocalizedDate } from "./LocalizedDate"; 18 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 ··· 134 record?.preferences?.showComments === false 135 ? 0 136 : doc.documents.comments_on_documents[0].count || 0; 137 138 return ( 139 <React.Fragment key={doc.documents?.uri}> ··· 162 )}{" "} 163 </p> 164 {comments > 0 || quotes > 0 ? "| " : ""} 165 - {quotes > 0 && ( 166 - <SpeedyLink 167 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 168 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 169 - > 170 - <QuoteTiny /> {quotes} 171 - </SpeedyLink> 172 - )} 173 - {comments > 0 && 174 - record?.preferences?.showComments !== false && ( 175 - <SpeedyLink 176 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 177 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 178 - > 179 - <CommentTiny /> {comments} 180 - </SpeedyLink> 181 - )} 182 </div> 183 </div> 184 <hr className="last:hidden border-border-light" />
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 ··· 135 record?.preferences?.showComments === false 136 ? 0 137 : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 139 140 return ( 141 <React.Fragment key={doc.documents?.uri}> ··· 164 )}{" "} 165 </p> 166 {comments > 0 || quotes > 0 ? "| " : ""} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 174 </div> 175 </div> 176 <hr className="last:hidden border-border-light" />
+91
app/lish/uri/[uri]/route.ts
···
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtUri } from "@atproto/api"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + 6 + /** 7 + * Redirect route for AT URIs (publications and documents) 8 + * Redirects to the actual hosted domains from publication records 9 + */ 10 + export async function GET( 11 + request: NextRequest, 12 + { params }: { params: Promise<{ uri: string }> } 13 + ) { 14 + try { 15 + const { uri: uriParam } = await params; 16 + const atUriString = decodeURIComponent(uriParam); 17 + const uri = new AtUri(atUriString); 18 + 19 + if (uri.collection === "pub.leaflet.publication") { 20 + // Get the publication record to retrieve base_path 21 + const { data: publication } = await supabaseServerClient 22 + .from("publications") 23 + .select("record") 24 + .eq("uri", atUriString) 25 + .single(); 26 + 27 + if (!publication?.record) { 28 + return new NextResponse("Publication not found", { status: 404 }); 29 + } 30 + 31 + const record = publication.record as PubLeafletPublication.Record; 32 + const basePath = record.base_path; 33 + 34 + if (!basePath) { 35 + return new NextResponse("Publication has no base_path", { status: 404 }); 36 + } 37 + 38 + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 39 + return NextResponse.redirect(basePath, 307); 40 + } else if (uri.collection === "pub.leaflet.document") { 41 + // Document link - need to find the publication it belongs to 42 + const { data: docInPub } = await supabaseServerClient 43 + .from("documents_in_publications") 44 + .select("publication, publications!inner(record)") 45 + .eq("document", atUriString) 46 + .single(); 47 + 48 + if (docInPub?.publication && docInPub.publications) { 49 + // Document is in a publication - redirect to domain/rkey 50 + const record = docInPub.publications.record as PubLeafletPublication.Record; 51 + const basePath = record.base_path; 52 + 53 + if (!basePath) { 54 + return new NextResponse("Publication has no base_path", { status: 404 }); 55 + } 56 + 57 + // Ensure basePath ends without trailing slash 58 + const cleanBasePath = basePath.endsWith("/") 59 + ? basePath.slice(0, -1) 60 + : basePath; 61 + 62 + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 63 + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); 64 + } 65 + 66 + // If not in a publication, check if it's a standalone document 67 + const { data: doc } = await supabaseServerClient 68 + .from("documents") 69 + .select("uri") 70 + .eq("uri", atUriString) 71 + .single(); 72 + 73 + if (doc) { 74 + // Standalone document - redirect to /p/did/rkey (temporary redirect) 75 + return NextResponse.redirect( 76 + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 77 + 307 78 + ); 79 + } 80 + 81 + // Document not found 82 + return new NextResponse("Document not found", { status: 404 }); 83 + } 84 + 85 + // Unsupported collection type 86 + return new NextResponse("Unsupported URI type", { status: 400 }); 87 + } catch (error) { 88 + console.error("Error resolving AT URI:", error); 89 + return new NextResponse("Invalid URI", { status: 400 }); 90 + } 91 + }
+12 -14
components/ActionBar/ActionButton.tsx
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 ··· 11 _props: ButtonProps & { 12 id?: string; 13 icon: React.ReactNode; 14 - label?: React.ReactNode; 15 primary?: boolean; 16 secondary?: boolean; 17 nav?: boolean; ··· 69 `} 70 > 71 <div className="shrink-0">{icon}</div> 72 - {label && ( 73 - <div 74 - className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 75 - > 76 - <div className="truncate text-left pt-[1px]">{label}</div> 77 - {subtext && ( 78 - <div className="text-xs text-tertiary font-normal text-left"> 79 - {subtext} 80 - </div> 81 - )} 82 - </div> 83 - )} 84 </button> 85 ); 86 };
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 ··· 11 _props: ButtonProps & { 12 id?: string; 13 icon: React.ReactNode; 14 + label: React.ReactNode; 15 primary?: boolean; 16 secondary?: boolean; 17 nav?: boolean; ··· 69 `} 70 > 71 <div className="shrink-0">{icon}</div> 72 + <div 73 + className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 74 + > 75 + <div className="truncate text-left pt-[1px]">{label}</div> 76 + {subtext && ( 77 + <div className="text-xs text-tertiary font-normal text-left"> 78 + {subtext} 79 + </div> 80 + )} 81 + </div> 82 </button> 83 ); 84 };
+4 -8
components/ActionBar/Navigation.tsx
··· 24 | "pub" 25 | "discover" 26 | "notifications" 27 - | "looseleafs"; 28 29 export const DesktopNavigation = (props: { 30 currentPage: navPages; ··· 126 }; 127 128 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 if (!props.subs) return; 132 return ( 133 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 <ActionButton 135 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 137 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 142 /> 143 </SpeedyLink> 144 );
··· 24 | "pub" 25 | "discover" 26 | "notifications" 27 + | "looseleafs" 28 + | "tag"; 29 30 export const DesktopNavigation = (props: { 31 currentPage: navPages; ··· 127 }; 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 130 if (!props.subs) return; 131 return ( 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 133 <ActionButton 134 nav 135 + icon={<ReaderUnreadSmall />} 136 label="Reader" 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 138 /> 139 </SpeedyLink> 140 );
+2 -3
components/ActionBar/Publications.tsx
··· 23 currentPubUri: string | undefined; 24 }) => { 25 let { identity } = useIdentityData(); 26 - let hasLooseleafs = identity?.permission_token_on_homepage.find( 27 (f) => 28 f.permission_tokens.leaflets_to_documents && 29 - f.permission_tokens.leaflets_to_documents.document, 30 ); 31 - console.log(hasLooseleafs); 32 33 // don't show pub list button if not logged in or no pub list 34 // we show a "start a pub" banner instead
··· 23 currentPubUri: string | undefined; 24 }) => { 25 let { identity } = useIdentityData(); 26 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 27 (f) => 28 f.permission_tokens.leaflets_to_documents && 29 + f.permission_tokens.leaflets_to_documents[0]?.document, 30 ); 31 32 // don't show pub list button if not logged in or no pub list 33 // we show a "start a pub" banner instead
+46
components/AtMentionLink.tsx
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { atUriToUrl } from "src/utils/mentionUtils"; 3 + 4 + /** 5 + * Component for rendering at-uri mentions (publications and documents) as clickable links. 6 + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering 7 + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. 8 + */ 9 + export function AtMentionLink({ 10 + atURI, 11 + children, 12 + className = "", 13 + }: { 14 + atURI: string; 15 + children: React.ReactNode; 16 + className?: string; 17 + }) { 18 + const aturi = new AtUri(atURI); 19 + const isPublication = aturi.collection === "pub.leaflet.publication"; 20 + const isDocument = aturi.collection === "pub.leaflet.document"; 21 + 22 + // Show publication icon if available 23 + const icon = 24 + isPublication || isDocument ? ( 25 + <img 26 + src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 + className="inline-block w-5 h-5 rounded-full mr-1 align-text-top" 28 + alt="" 29 + width="20" 30 + height="20" 31 + loading="lazy" 32 + /> 33 + ) : null; 34 + 35 + return ( 36 + <a 37 + href={atUriToUrl(atURI)} 38 + target="_blank" 39 + rel="noopener noreferrer" 40 + className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+4 -2
components/Blocks/BlockCommandBar.tsx
··· 37 const clearCommandSearchText = () => { 38 if (!props.entityID) return; 39 const entityID = props.entityID; 40 - 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 if (!existingState) return; 43 ··· 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 useEffect(() => { 73 let listener = async (e: KeyboardEvent) => { 74 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 return; 119 } 120 }; 121 window.addEventListener("keydown", listener); 122 123 return () => window.removeEventListener("keydown", listener); ··· 200 201 return ( 202 <button 203 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 204 onMouseOver={() => { 205 props.setHighlighted(props.name); 206 }}
··· 37 const clearCommandSearchText = () => { 38 if (!props.entityID) return; 39 const entityID = props.entityID; 40 + 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 if (!existingState) return; 43 ··· 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 + 73 useEffect(() => { 74 let listener = async (e: KeyboardEvent) => { 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 119 return; 120 } 121 }; 122 + 123 window.addEventListener("keydown", listener); 124 125 return () => window.removeEventListener("keydown", listener); ··· 202 203 return ( 204 <button 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 206 onMouseOver={() => { 207 props.setHighlighted(props.name); 208 }}
+3 -3
components/Blocks/BlockCommands.tsx
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 + import { focusPage } from "src/utils/focusPage"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
+2 -120
components/Blocks/DeleteBlock.tsx
··· 1 - import { 2 - Fact, 3 - ReplicacheMutators, 4 - useEntity, 5 - useReplicache, 6 - } from "src/replicache"; 7 - import { Replicache } from "replicache"; 8 - import { useUIState } from "src/useUIState"; 9 - import { scanIndex } from "src/replicache/utils"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { focusBlock } from "src/utils/focusBlock"; 12 import { ButtonPrimary } from "components/Buttons"; 13 import { CloseTiny } from "components/Icons/CloseTiny"; 14 15 export const AreYouSure = (props: { 16 entityID: string[] | string; ··· 82 ); 83 }; 84 85 - export async function deleteBlock( 86 - entities: string[], 87 - rep: Replicache<ReplicacheMutators>, 88 - ) { 89 - // get what pagess we need to close as a result of deleting this block 90 - let pagesToClose = [] as string[]; 91 - for (let entity of entities) { 92 - let [type] = await rep.query((tx) => 93 - scanIndex(tx).eav(entity, "block/type"), 94 - ); 95 - if (type.data.value === "card") { 96 - let [childPages] = await rep?.query( 97 - (tx) => scanIndex(tx).eav(entity, "block/card") || [], 98 - ); 99 - pagesToClose = [childPages?.data.value]; 100 - } 101 - if (type.data.value === "mailbox") { 102 - let [archive] = await rep?.query( 103 - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 104 - ); 105 - let [draft] = await rep?.query( 106 - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 107 - ); 108 - pagesToClose = [archive?.data.value, draft?.data.value]; 109 - } 110 - } 111 - 112 - // the next and previous blocks in the block list 113 - // if the focused thing is a page and not a block, return 114 - let focusedBlock = useUIState.getState().focusedEntity; 115 - let parent = 116 - focusedBlock?.entityType === "page" 117 - ? focusedBlock.entityID 118 - : focusedBlock?.parent; 119 - 120 - if (parent) { 121 - let parentType = await rep?.query((tx) => 122 - scanIndex(tx).eav(parent, "page/type"), 123 - ); 124 - if (parentType[0]?.data.value === "canvas") { 125 - useUIState 126 - .getState() 127 - .setFocusedBlock({ entityType: "page", entityID: parent }); 128 - useUIState.getState().setSelectedBlocks([]); 129 - } else { 130 - let siblings = 131 - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 132 - 133 - let selectedBlocks = useUIState.getState().selectedBlocks; 134 - let firstSelected = selectedBlocks[0]; 135 - let lastSelected = selectedBlocks[entities.length - 1]; 136 - 137 - let prevBlock = 138 - siblings?.[ 139 - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 140 - ]; 141 - let prevBlockType = await rep?.query((tx) => 142 - scanIndex(tx).eav(prevBlock?.value, "block/type"), 143 - ); 144 - 145 - let nextBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === lastSelected.value) + 1 148 - ]; 149 - let nextBlockType = await rep?.query((tx) => 150 - scanIndex(tx).eav(nextBlock?.value, "block/type"), 151 - ); 152 - 153 - if (prevBlock) { 154 - useUIState.getState().setSelectedBlock({ 155 - value: prevBlock.value, 156 - parent: prevBlock.parent, 157 - }); 158 - 159 - focusBlock( 160 - { 161 - value: prevBlock.value, 162 - type: prevBlockType?.[0].data.value, 163 - parent: prevBlock.parent, 164 - }, 165 - { type: "end" }, 166 - ); 167 - } else { 168 - useUIState.getState().setSelectedBlock({ 169 - value: nextBlock.value, 170 - parent: nextBlock.parent, 171 - }); 172 - 173 - focusBlock( 174 - { 175 - value: nextBlock.value, 176 - type: nextBlockType?.[0]?.data.value, 177 - parent: nextBlock.parent, 178 - }, 179 - { type: "start" }, 180 - ); 181 - } 182 - } 183 - } 184 - 185 - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 186 - await Promise.all( 187 - entities.map((entity) => 188 - rep?.mutate.removeBlock({ 189 - blockEntity: entity, 190 - }), 191 - ), 192 - ); 193 - }
··· 1 + import { Fact, useReplicache } from "src/replicache"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 5 6 export const AreYouSure = (props: { 7 entityID: string[] | string; ··· 73 ); 74 }; 75
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 12 import { isUrl } from "src/utils/isURL"; 13 import { elementId } from "src/utils/elementId"; 14 import { focusBlock } from "src/utils/focusBlock";
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 13 import { isUrl } from "src/utils/isURL"; 14 import { elementId } from "src/utils/elementId"; 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 + import { focusPage } from "src/utils/focusPage"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 - import { focusPage } from "components/Pages"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 + import { focusPage } from "src/utils/focusPage"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
+501
components/Blocks/PollBlock/index.tsx
···
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "../Block"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 + import { useCallback, useEffect, useState } from "react"; 5 + import { Input } from "components/Input"; 6 + import { focusElement } from "src/utils/focusElement"; 7 + import { Separator } from "components/Layout"; 8 + import { useEntitySetContext } from "components/EntitySetProvider"; 9 + import { theme } from "tailwind.config"; 10 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { v7 } from "uuid"; 12 + import { 13 + useLeafletPublicationData, 14 + usePollData, 15 + } from "components/PageSWRDataProvider"; 16 + import { voteOnPoll } from "actions/pollActions"; 17 + import { elementId } from "src/utils/elementId"; 18 + import { CheckTiny } from "components/Icons/CheckTiny"; 19 + import { CloseTiny } from "components/Icons/CloseTiny"; 20 + import { PublicationPollBlock } from "../PublicationPollBlock"; 21 + import { usePollBlockUIState } from "./pollBlockState"; 22 + 23 + export const PollBlock = (props: BlockProps) => { 24 + let { data: pub } = useLeafletPublicationData(); 25 + if (!pub) return <LeafletPollBlock {...props} />; 26 + return <PublicationPollBlock {...props} />; 27 + }; 28 + 29 + export const LeafletPollBlock = (props: BlockProps) => { 30 + let isSelected = useUIState((s) => 31 + s.selectedBlocks.find((b) => b.value === props.entityID), 32 + ); 33 + let { permissions } = useEntitySetContext(); 34 + 35 + let { data: pollData } = usePollData(); 36 + let hasVoted = 37 + pollData?.voter_token && 38 + pollData.polls.find( 39 + (v) => 40 + v.poll_votes_on_entity.voter_token === pollData.voter_token && 41 + v.poll_votes_on_entity.poll_entity === props.entityID, 42 + ); 43 + 44 + let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 45 + if (!pollState) { 46 + if (hasVoted) pollState = "results"; 47 + else pollState = "voting"; 48 + } 49 + 50 + const setPollState = useCallback( 51 + (state: "editing" | "voting" | "results") => { 52 + usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 53 + }, 54 + [], 55 + ); 56 + 57 + let votes = 58 + pollData?.polls.filter( 59 + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 60 + ) || []; 61 + let totalVotes = votes.length; 62 + 63 + return ( 64 + <div 65 + className={`poll flex flex-col gap-2 p-3 w-full 66 + ${isSelected ? "block-border-selected " : "block-border"}`} 67 + style={{ 68 + backgroundColor: 69 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 70 + }} 71 + > 72 + {pollState === "editing" ? ( 73 + <EditPoll 74 + totalVotes={totalVotes} 75 + votes={votes.map((v) => v.poll_votes_on_entity)} 76 + entityID={props.entityID} 77 + close={() => { 78 + if (hasVoted) setPollState("results"); 79 + else setPollState("voting"); 80 + }} 81 + /> 82 + ) : pollState === "results" ? ( 83 + <PollResults 84 + entityID={props.entityID} 85 + pollState={pollState} 86 + setPollState={setPollState} 87 + hasVoted={!!hasVoted} 88 + /> 89 + ) : ( 90 + <PollVote 91 + entityID={props.entityID} 92 + onSubmit={() => setPollState("results")} 93 + pollState={pollState} 94 + setPollState={setPollState} 95 + hasVoted={!!hasVoted} 96 + /> 97 + )} 98 + </div> 99 + ); 100 + }; 101 + 102 + const PollVote = (props: { 103 + entityID: string; 104 + onSubmit: () => void; 105 + pollState: "editing" | "voting" | "results"; 106 + setPollState: (pollState: "editing" | "voting" | "results") => void; 107 + hasVoted: boolean; 108 + }) => { 109 + let { data, mutate } = usePollData(); 110 + let { permissions } = useEntitySetContext(); 111 + 112 + let pollOptions = useEntity(props.entityID, "poll/options"); 113 + let currentVotes = data?.voter_token 114 + ? data.polls 115 + .filter( 116 + (p) => 117 + p.poll_votes_on_entity.poll_entity === props.entityID && 118 + p.poll_votes_on_entity.voter_token === data.voter_token, 119 + ) 120 + .map((v) => v.poll_votes_on_entity.option_entity) 121 + : []; 122 + let [selectedPollOptions, setSelectedPollOptions] = 123 + useState<string[]>(currentVotes); 124 + 125 + return ( 126 + <> 127 + {pollOptions.map((option, index) => ( 128 + <PollVoteButton 129 + key={option.data.value} 130 + selected={selectedPollOptions.includes(option.data.value)} 131 + toggleSelected={() => 132 + setSelectedPollOptions((s) => 133 + s.includes(option.data.value) 134 + ? s.filter((s) => s !== option.data.value) 135 + : [...s, option.data.value], 136 + ) 137 + } 138 + entityID={option.data.value} 139 + /> 140 + ))} 141 + <div className="flex justify-between items-center"> 142 + <div className="flex justify-end gap-2"> 143 + {permissions.write && ( 144 + <button 145 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 146 + onClick={() => { 147 + props.setPollState("editing"); 148 + }} 149 + > 150 + Edit Options 151 + </button> 152 + )} 153 + 154 + {permissions.write && <Separator classname="h-6" />} 155 + <PollStateToggle 156 + setPollState={props.setPollState} 157 + pollState={props.pollState} 158 + hasVoted={props.hasVoted} 159 + /> 160 + </div> 161 + <ButtonPrimary 162 + className="place-self-end" 163 + onClick={async () => { 164 + await voteOnPoll(props.entityID, selectedPollOptions); 165 + mutate((oldState) => { 166 + if (!oldState || !oldState.voter_token) return; 167 + return { 168 + ...oldState, 169 + polls: [ 170 + ...oldState.polls.filter( 171 + (p) => 172 + !( 173 + p.poll_votes_on_entity.voter_token === 174 + oldState.voter_token && 175 + p.poll_votes_on_entity.poll_entity == props.entityID 176 + ), 177 + ), 178 + ...selectedPollOptions.map((option_entity) => ({ 179 + poll_votes_on_entity: { 180 + option_entity, 181 + entities: { set: "" }, 182 + poll_entity: props.entityID, 183 + voter_token: oldState.voter_token!, 184 + }, 185 + })), 186 + ], 187 + }; 188 + }); 189 + props.onSubmit(); 190 + }} 191 + disabled={ 192 + selectedPollOptions.length === 0 || 193 + (selectedPollOptions.length === currentVotes.length && 194 + selectedPollOptions.every((s) => currentVotes.includes(s))) 195 + } 196 + > 197 + Vote! 198 + </ButtonPrimary> 199 + </div> 200 + </> 201 + ); 202 + }; 203 + const PollVoteButton = (props: { 204 + entityID: string; 205 + selected: boolean; 206 + toggleSelected: () => void; 207 + }) => { 208 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 209 + if (!optionName) return null; 210 + if (props.selected) 211 + return ( 212 + <div className="flex gap-2 items-center"> 213 + <ButtonPrimary 214 + className={`pollOption grow max-w-full flex`} 215 + onClick={() => { 216 + props.toggleSelected(); 217 + }} 218 + > 219 + {optionName} 220 + </ButtonPrimary> 221 + </div> 222 + ); 223 + return ( 224 + <div className="flex gap-2 items-center"> 225 + <ButtonSecondary 226 + className={`pollOption grow max-w-full flex`} 227 + onClick={() => { 228 + props.toggleSelected(); 229 + }} 230 + > 231 + {optionName} 232 + </ButtonSecondary> 233 + </div> 234 + ); 235 + }; 236 + 237 + const PollResults = (props: { 238 + entityID: string; 239 + pollState: "editing" | "voting" | "results"; 240 + setPollState: (pollState: "editing" | "voting" | "results") => void; 241 + hasVoted: boolean; 242 + }) => { 243 + let { data } = usePollData(); 244 + let { permissions } = useEntitySetContext(); 245 + let pollOptions = useEntity(props.entityID, "poll/options"); 246 + let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 247 + let votesByOptions = pollData?.votesByOption || {}; 248 + let highestVotes = Math.max(...Object.values(votesByOptions)); 249 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 250 + (winningEntities, [entity, votes]) => { 251 + if (votes === highestVotes) winningEntities.push(entity); 252 + return winningEntities; 253 + }, 254 + [], 255 + ); 256 + return ( 257 + <> 258 + {pollOptions.map((p) => ( 259 + <PollResult 260 + key={p.id} 261 + winner={winningOptionEntities.includes(p.data.value)} 262 + entityID={p.data.value} 263 + totalVotes={pollData?.unique_votes || 0} 264 + votes={pollData?.votesByOption[p.data.value] || 0} 265 + /> 266 + ))} 267 + <div className="flex gap-2"> 268 + {permissions.write && ( 269 + <button 270 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 271 + onClick={() => { 272 + props.setPollState("editing"); 273 + }} 274 + > 275 + Edit Options 276 + </button> 277 + )} 278 + 279 + {permissions.write && <Separator classname="h-6" />} 280 + <PollStateToggle 281 + setPollState={props.setPollState} 282 + pollState={props.pollState} 283 + hasVoted={props.hasVoted} 284 + /> 285 + </div> 286 + </> 287 + ); 288 + }; 289 + 290 + const PollResult = (props: { 291 + entityID: string; 292 + votes: number; 293 + totalVotes: number; 294 + winner: boolean; 295 + }) => { 296 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 297 + return ( 298 + <div 299 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 300 + > 301 + <div 302 + style={{ 303 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 304 + paintOrder: "stroke fill", 305 + }} 306 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 307 + > 308 + <div className="grow max-w-full truncate">{optionName}</div> 309 + <div>{props.votes}</div> 310 + </div> 311 + <div 312 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 313 + > 314 + <div 315 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 316 + style={{ 317 + maskImage: "var(--hatchSVG)", 318 + maskRepeat: "repeat repeat", 319 + 320 + ...(props.votes === 0 321 + ? { width: "4px" } 322 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 323 + }} 324 + /> 325 + <div /> 326 + </div> 327 + </div> 328 + ); 329 + }; 330 + 331 + const EditPoll = (props: { 332 + votes: { option_entity: string }[]; 333 + totalVotes: number; 334 + entityID: string; 335 + close: () => void; 336 + }) => { 337 + let pollOptions = useEntity(props.entityID, "poll/options"); 338 + let { rep } = useReplicache(); 339 + let permission_set = useEntitySetContext(); 340 + let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 341 + [k: string]: string; 342 + }>({}); 343 + return ( 344 + <> 345 + {props.totalVotes > 0 && ( 346 + <div className="text-sm italic text-tertiary"> 347 + You can&apos;t edit options people already voted for! 348 + </div> 349 + )} 350 + 351 + {pollOptions.length === 0 && ( 352 + <div className="text-center italic text-tertiary text-sm"> 353 + no options yet... 354 + </div> 355 + )} 356 + {pollOptions.map((p) => ( 357 + <EditPollOption 358 + key={p.id} 359 + entityID={p.data.value} 360 + pollEntity={props.entityID} 361 + disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 362 + localNameState={localPollOptionNames[p.data.value]} 363 + setLocalNameState={setLocalPollOptionNames} 364 + /> 365 + ))} 366 + 367 + <button 368 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 369 + onClick={async () => { 370 + let pollOptionEntity = v7(); 371 + await rep?.mutate.addPollOption({ 372 + pollEntity: props.entityID, 373 + pollOptionEntity, 374 + pollOptionName: "", 375 + permission_set: permission_set.set, 376 + factID: v7(), 377 + }); 378 + 379 + focusElement( 380 + document.getElementById( 381 + elementId.block(props.entityID).pollInput(pollOptionEntity), 382 + ) as HTMLInputElement | null, 383 + ); 384 + }} 385 + > 386 + Add an Option 387 + </button> 388 + 389 + <hr className="border-border" /> 390 + <ButtonPrimary 391 + className="place-self-end" 392 + onClick={async () => { 393 + // remove any poll options that have no name 394 + // look through the localPollOptionNames object and remove any options that have no name 395 + let emptyOptions = Object.entries(localPollOptionNames).filter( 396 + ([optionEntity, optionName]) => optionName === "", 397 + ); 398 + await Promise.all( 399 + emptyOptions.map( 400 + async ([entity]) => 401 + await rep?.mutate.removePollOption({ 402 + optionEntity: entity, 403 + }), 404 + ), 405 + ); 406 + 407 + await rep?.mutate.assertFact( 408 + Object.entries(localPollOptionNames) 409 + .filter(([, name]) => !!name) 410 + .map(([entity, name]) => ({ 411 + entity, 412 + attribute: "poll-option/name", 413 + data: { type: "string", value: name }, 414 + })), 415 + ); 416 + props.close(); 417 + }} 418 + > 419 + Save <CheckTiny /> 420 + </ButtonPrimary> 421 + </> 422 + ); 423 + }; 424 + 425 + const EditPollOption = (props: { 426 + entityID: string; 427 + pollEntity: string; 428 + localNameState: string | undefined; 429 + setLocalNameState: ( 430 + s: (s: { [k: string]: string }) => { [k: string]: string }, 431 + ) => void; 432 + disabled: boolean; 433 + }) => { 434 + let { rep } = useReplicache(); 435 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 436 + useEffect(() => { 437 + props.setLocalNameState((s) => ({ 438 + ...s, 439 + [props.entityID]: optionName || "", 440 + })); 441 + }, [optionName, props.setLocalNameState, props.entityID]); 442 + 443 + return ( 444 + <div className="flex gap-2 items-center"> 445 + <Input 446 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 447 + type="text" 448 + className="pollOptionInput w-full input-with-border" 449 + placeholder="Option here..." 450 + disabled={props.disabled} 451 + value={ 452 + props.localNameState === undefined ? optionName : props.localNameState 453 + } 454 + onChange={(e) => { 455 + props.setLocalNameState((s) => ({ 456 + ...s, 457 + [props.entityID]: e.target.value, 458 + })); 459 + }} 460 + onKeyDown={(e) => { 461 + if (e.key === "Backspace" && !e.currentTarget.value) { 462 + e.preventDefault(); 463 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 464 + } 465 + }} 466 + /> 467 + 468 + <button 469 + tabIndex={-1} 470 + disabled={props.disabled} 471 + className="text-accent-contrast disabled:text-border" 472 + onMouseDown={async () => { 473 + await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 474 + }} 475 + > 476 + <CloseTiny /> 477 + </button> 478 + </div> 479 + ); 480 + }; 481 + 482 + const PollStateToggle = (props: { 483 + setPollState: (pollState: "editing" | "voting" | "results") => void; 484 + hasVoted: boolean; 485 + pollState: "editing" | "voting" | "results"; 486 + }) => { 487 + return ( 488 + <button 489 + className="text-sm text-accent-contrast sm:hover:underline" 490 + onClick={() => { 491 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 + }} 493 + > 494 + {props.pollState === "voting" 495 + ? "See Results" 496 + : props.hasVoted 497 + ? "Change Vote" 498 + : "Back to Poll"} 499 + </button> 500 + ); 501 + };
+8
components/Blocks/PollBlock/pollBlockState.ts
···
··· 1 + import { create } from "zustand"; 2 + 3 + export let usePollBlockUIState = create( 4 + () => 5 + ({}) as { 6 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 7 + }, 8 + );
-507
components/Blocks/PollBlock.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 3 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 - import { useCallback, useEffect, useState } from "react"; 5 - import { focusElement, Input } from "components/Input"; 6 - import { Separator } from "components/Layout"; 7 - import { useEntitySetContext } from "components/EntitySetProvider"; 8 - import { theme } from "tailwind.config"; 9 - import { useEntity, useReplicache } from "src/replicache"; 10 - import { v7 } from "uuid"; 11 - import { 12 - useLeafletPublicationData, 13 - usePollData, 14 - } from "components/PageSWRDataProvider"; 15 - import { voteOnPoll } from "actions/pollActions"; 16 - import { create } from "zustand"; 17 - import { elementId } from "src/utils/elementId"; 18 - import { CheckTiny } from "components/Icons/CheckTiny"; 19 - import { CloseTiny } from "components/Icons/CloseTiny"; 20 - import { PublicationPollBlock } from "./PublicationPollBlock"; 21 - 22 - export let usePollBlockUIState = create( 23 - () => 24 - ({}) as { 25 - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 26 - }, 27 - ); 28 - 29 - export const PollBlock = (props: BlockProps) => { 30 - let { data: pub } = useLeafletPublicationData(); 31 - if (!pub) return <LeafletPollBlock {...props} />; 32 - return <PublicationPollBlock {...props} />; 33 - }; 34 - 35 - export const LeafletPollBlock = (props: BlockProps) => { 36 - let isSelected = useUIState((s) => 37 - s.selectedBlocks.find((b) => b.value === props.entityID), 38 - ); 39 - let { permissions } = useEntitySetContext(); 40 - 41 - let { data: pollData } = usePollData(); 42 - let hasVoted = 43 - pollData?.voter_token && 44 - pollData.polls.find( 45 - (v) => 46 - v.poll_votes_on_entity.voter_token === pollData.voter_token && 47 - v.poll_votes_on_entity.poll_entity === props.entityID, 48 - ); 49 - 50 - let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 51 - if (!pollState) { 52 - if (hasVoted) pollState = "results"; 53 - else pollState = "voting"; 54 - } 55 - 56 - const setPollState = useCallback( 57 - (state: "editing" | "voting" | "results") => { 58 - usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 59 - }, 60 - [], 61 - ); 62 - 63 - let votes = 64 - pollData?.polls.filter( 65 - (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 66 - ) || []; 67 - let totalVotes = votes.length; 68 - 69 - return ( 70 - <div 71 - className={`poll flex flex-col gap-2 p-3 w-full 72 - ${isSelected ? "block-border-selected " : "block-border"}`} 73 - style={{ 74 - backgroundColor: 75 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 76 - }} 77 - > 78 - {pollState === "editing" ? ( 79 - <EditPoll 80 - totalVotes={totalVotes} 81 - votes={votes.map((v) => v.poll_votes_on_entity)} 82 - entityID={props.entityID} 83 - close={() => { 84 - if (hasVoted) setPollState("results"); 85 - else setPollState("voting"); 86 - }} 87 - /> 88 - ) : pollState === "results" ? ( 89 - <PollResults 90 - entityID={props.entityID} 91 - pollState={pollState} 92 - setPollState={setPollState} 93 - hasVoted={!!hasVoted} 94 - /> 95 - ) : ( 96 - <PollVote 97 - entityID={props.entityID} 98 - onSubmit={() => setPollState("results")} 99 - pollState={pollState} 100 - setPollState={setPollState} 101 - hasVoted={!!hasVoted} 102 - /> 103 - )} 104 - </div> 105 - ); 106 - }; 107 - 108 - const PollVote = (props: { 109 - entityID: string; 110 - onSubmit: () => void; 111 - pollState: "editing" | "voting" | "results"; 112 - setPollState: (pollState: "editing" | "voting" | "results") => void; 113 - hasVoted: boolean; 114 - }) => { 115 - let { data, mutate } = usePollData(); 116 - let { permissions } = useEntitySetContext(); 117 - 118 - let pollOptions = useEntity(props.entityID, "poll/options"); 119 - let currentVotes = data?.voter_token 120 - ? data.polls 121 - .filter( 122 - (p) => 123 - p.poll_votes_on_entity.poll_entity === props.entityID && 124 - p.poll_votes_on_entity.voter_token === data.voter_token, 125 - ) 126 - .map((v) => v.poll_votes_on_entity.option_entity) 127 - : []; 128 - let [selectedPollOptions, setSelectedPollOptions] = 129 - useState<string[]>(currentVotes); 130 - 131 - return ( 132 - <> 133 - {pollOptions.map((option, index) => ( 134 - <PollVoteButton 135 - key={option.data.value} 136 - selected={selectedPollOptions.includes(option.data.value)} 137 - toggleSelected={() => 138 - setSelectedPollOptions((s) => 139 - s.includes(option.data.value) 140 - ? s.filter((s) => s !== option.data.value) 141 - : [...s, option.data.value], 142 - ) 143 - } 144 - entityID={option.data.value} 145 - /> 146 - ))} 147 - <div className="flex justify-between items-center"> 148 - <div className="flex justify-end gap-2"> 149 - {permissions.write && ( 150 - <button 151 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 152 - onClick={() => { 153 - props.setPollState("editing"); 154 - }} 155 - > 156 - Edit Options 157 - </button> 158 - )} 159 - 160 - {permissions.write && <Separator classname="h-6" />} 161 - <PollStateToggle 162 - setPollState={props.setPollState} 163 - pollState={props.pollState} 164 - hasVoted={props.hasVoted} 165 - /> 166 - </div> 167 - <ButtonPrimary 168 - className="place-self-end" 169 - onClick={async () => { 170 - await voteOnPoll(props.entityID, selectedPollOptions); 171 - mutate((oldState) => { 172 - if (!oldState || !oldState.voter_token) return; 173 - return { 174 - ...oldState, 175 - polls: [ 176 - ...oldState.polls.filter( 177 - (p) => 178 - !( 179 - p.poll_votes_on_entity.voter_token === 180 - oldState.voter_token && 181 - p.poll_votes_on_entity.poll_entity == props.entityID 182 - ), 183 - ), 184 - ...selectedPollOptions.map((option_entity) => ({ 185 - poll_votes_on_entity: { 186 - option_entity, 187 - entities: { set: "" }, 188 - poll_entity: props.entityID, 189 - voter_token: oldState.voter_token!, 190 - }, 191 - })), 192 - ], 193 - }; 194 - }); 195 - props.onSubmit(); 196 - }} 197 - disabled={ 198 - selectedPollOptions.length === 0 || 199 - (selectedPollOptions.length === currentVotes.length && 200 - selectedPollOptions.every((s) => currentVotes.includes(s))) 201 - } 202 - > 203 - Vote! 204 - </ButtonPrimary> 205 - </div> 206 - </> 207 - ); 208 - }; 209 - const PollVoteButton = (props: { 210 - entityID: string; 211 - selected: boolean; 212 - toggleSelected: () => void; 213 - }) => { 214 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 215 - if (!optionName) return null; 216 - if (props.selected) 217 - return ( 218 - <div className="flex gap-2 items-center"> 219 - <ButtonPrimary 220 - className={`pollOption grow max-w-full flex`} 221 - onClick={() => { 222 - props.toggleSelected(); 223 - }} 224 - > 225 - {optionName} 226 - </ButtonPrimary> 227 - </div> 228 - ); 229 - return ( 230 - <div className="flex gap-2 items-center"> 231 - <ButtonSecondary 232 - className={`pollOption grow max-w-full flex`} 233 - onClick={() => { 234 - props.toggleSelected(); 235 - }} 236 - > 237 - {optionName} 238 - </ButtonSecondary> 239 - </div> 240 - ); 241 - }; 242 - 243 - const PollResults = (props: { 244 - entityID: string; 245 - pollState: "editing" | "voting" | "results"; 246 - setPollState: (pollState: "editing" | "voting" | "results") => void; 247 - hasVoted: boolean; 248 - }) => { 249 - let { data } = usePollData(); 250 - let { permissions } = useEntitySetContext(); 251 - let pollOptions = useEntity(props.entityID, "poll/options"); 252 - let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 253 - let votesByOptions = pollData?.votesByOption || {}; 254 - let highestVotes = Math.max(...Object.values(votesByOptions)); 255 - let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 256 - (winningEntities, [entity, votes]) => { 257 - if (votes === highestVotes) winningEntities.push(entity); 258 - return winningEntities; 259 - }, 260 - [], 261 - ); 262 - return ( 263 - <> 264 - {pollOptions.map((p) => ( 265 - <PollResult 266 - key={p.id} 267 - winner={winningOptionEntities.includes(p.data.value)} 268 - entityID={p.data.value} 269 - totalVotes={pollData?.unique_votes || 0} 270 - votes={pollData?.votesByOption[p.data.value] || 0} 271 - /> 272 - ))} 273 - <div className="flex gap-2"> 274 - {permissions.write && ( 275 - <button 276 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 277 - onClick={() => { 278 - props.setPollState("editing"); 279 - }} 280 - > 281 - Edit Options 282 - </button> 283 - )} 284 - 285 - {permissions.write && <Separator classname="h-6" />} 286 - <PollStateToggle 287 - setPollState={props.setPollState} 288 - pollState={props.pollState} 289 - hasVoted={props.hasVoted} 290 - /> 291 - </div> 292 - </> 293 - ); 294 - }; 295 - 296 - const PollResult = (props: { 297 - entityID: string; 298 - votes: number; 299 - totalVotes: number; 300 - winner: boolean; 301 - }) => { 302 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 303 - return ( 304 - <div 305 - className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 306 - > 307 - <div 308 - style={{ 309 - WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 310 - paintOrder: "stroke fill", 311 - }} 312 - className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 313 - > 314 - <div className="grow max-w-full truncate">{optionName}</div> 315 - <div>{props.votes}</div> 316 - </div> 317 - <div 318 - className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 319 - > 320 - <div 321 - className={`bg-accent-contrast rounded-[2px] m-0.5`} 322 - style={{ 323 - maskImage: "var(--hatchSVG)", 324 - maskRepeat: "repeat repeat", 325 - 326 - ...(props.votes === 0 327 - ? { width: "4px" } 328 - : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 329 - }} 330 - /> 331 - <div /> 332 - </div> 333 - </div> 334 - ); 335 - }; 336 - 337 - const EditPoll = (props: { 338 - votes: { option_entity: string }[]; 339 - totalVotes: number; 340 - entityID: string; 341 - close: () => void; 342 - }) => { 343 - let pollOptions = useEntity(props.entityID, "poll/options"); 344 - let { rep } = useReplicache(); 345 - let permission_set = useEntitySetContext(); 346 - let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 347 - [k: string]: string; 348 - }>({}); 349 - return ( 350 - <> 351 - {props.totalVotes > 0 && ( 352 - <div className="text-sm italic text-tertiary"> 353 - You can&apos;t edit options people already voted for! 354 - </div> 355 - )} 356 - 357 - {pollOptions.length === 0 && ( 358 - <div className="text-center italic text-tertiary text-sm"> 359 - no options yet... 360 - </div> 361 - )} 362 - {pollOptions.map((p) => ( 363 - <EditPollOption 364 - key={p.id} 365 - entityID={p.data.value} 366 - pollEntity={props.entityID} 367 - disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 368 - localNameState={localPollOptionNames[p.data.value]} 369 - setLocalNameState={setLocalPollOptionNames} 370 - /> 371 - ))} 372 - 373 - <button 374 - className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 375 - onClick={async () => { 376 - let pollOptionEntity = v7(); 377 - await rep?.mutate.addPollOption({ 378 - pollEntity: props.entityID, 379 - pollOptionEntity, 380 - pollOptionName: "", 381 - permission_set: permission_set.set, 382 - factID: v7(), 383 - }); 384 - 385 - focusElement( 386 - document.getElementById( 387 - elementId.block(props.entityID).pollInput(pollOptionEntity), 388 - ) as HTMLInputElement | null, 389 - ); 390 - }} 391 - > 392 - Add an Option 393 - </button> 394 - 395 - <hr className="border-border" /> 396 - <ButtonPrimary 397 - className="place-self-end" 398 - onClick={async () => { 399 - // remove any poll options that have no name 400 - // look through the localPollOptionNames object and remove any options that have no name 401 - let emptyOptions = Object.entries(localPollOptionNames).filter( 402 - ([optionEntity, optionName]) => optionName === "", 403 - ); 404 - await Promise.all( 405 - emptyOptions.map( 406 - async ([entity]) => 407 - await rep?.mutate.removePollOption({ 408 - optionEntity: entity, 409 - }), 410 - ), 411 - ); 412 - 413 - await rep?.mutate.assertFact( 414 - Object.entries(localPollOptionNames) 415 - .filter(([, name]) => !!name) 416 - .map(([entity, name]) => ({ 417 - entity, 418 - attribute: "poll-option/name", 419 - data: { type: "string", value: name }, 420 - })), 421 - ); 422 - props.close(); 423 - }} 424 - > 425 - Save <CheckTiny /> 426 - </ButtonPrimary> 427 - </> 428 - ); 429 - }; 430 - 431 - const EditPollOption = (props: { 432 - entityID: string; 433 - pollEntity: string; 434 - localNameState: string | undefined; 435 - setLocalNameState: ( 436 - s: (s: { [k: string]: string }) => { [k: string]: string }, 437 - ) => void; 438 - disabled: boolean; 439 - }) => { 440 - let { rep } = useReplicache(); 441 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 442 - useEffect(() => { 443 - props.setLocalNameState((s) => ({ 444 - ...s, 445 - [props.entityID]: optionName || "", 446 - })); 447 - }, [optionName, props.setLocalNameState, props.entityID]); 448 - 449 - return ( 450 - <div className="flex gap-2 items-center"> 451 - <Input 452 - id={elementId.block(props.pollEntity).pollInput(props.entityID)} 453 - type="text" 454 - className="pollOptionInput w-full input-with-border" 455 - placeholder="Option here..." 456 - disabled={props.disabled} 457 - value={ 458 - props.localNameState === undefined ? optionName : props.localNameState 459 - } 460 - onChange={(e) => { 461 - props.setLocalNameState((s) => ({ 462 - ...s, 463 - [props.entityID]: e.target.value, 464 - })); 465 - }} 466 - onKeyDown={(e) => { 467 - if (e.key === "Backspace" && !e.currentTarget.value) { 468 - e.preventDefault(); 469 - rep?.mutate.removePollOption({ optionEntity: props.entityID }); 470 - } 471 - }} 472 - /> 473 - 474 - <button 475 - tabIndex={-1} 476 - disabled={props.disabled} 477 - className="text-accent-contrast disabled:text-border" 478 - onMouseDown={async () => { 479 - await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 480 - }} 481 - > 482 - <CloseTiny /> 483 - </button> 484 - </div> 485 - ); 486 - }; 487 - 488 - const PollStateToggle = (props: { 489 - setPollState: (pollState: "editing" | "voting" | "results") => void; 490 - hasVoted: boolean; 491 - pollState: "editing" | "voting" | "results"; 492 - }) => { 493 - return ( 494 - <button 495 - className="text-sm text-accent-contrast sm:hover:underline" 496 - onClick={() => { 497 - props.setPollState(props.pollState === "voting" ? "results" : "voting"); 498 - }} 499 - > 500 - {props.pollState === "voting" 501 - ? "See Results" 502 - : props.hasVoted 503 - ? "Change Vote" 504 - : "Back to Poll"} 505 - </button> 506 - ); 507 - };
···
+2 -1
components/Blocks/PublicationPollBlock.tsx
··· 1 import { useUIState } from "src/useUIState"; 2 import { BlockProps } from "./Block"; 3 import { useMemo } from "react"; 4 - import { focusElement, AsyncValueInput } from "components/Input"; 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { v7 } from "uuid";
··· 1 import { useUIState } from "src/useUIState"; 2 import { BlockProps } from "./Block"; 3 import { useMemo } from "react"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 6 import { useEntitySetContext } from "components/EntitySetProvider"; 7 import { useEntity, useReplicache } from "src/replicache"; 8 import { v7 } from "uuid";
+31 -35
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 7 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 export function RenderYJSFragment({ ··· 64 return <br key={index} />; 65 } 66 67 return null; 68 }) 69 )} ··· 101 } 102 }; 103 104 - export type Delta = { 105 - insert: string; 106 - attributes?: { 107 - strong?: {}; 108 - code?: {}; 109 - em?: {}; 110 - underline?: {}; 111 - strikethrough?: {}; 112 - highlight?: { color: string }; 113 - link?: { href: string }; 114 - }; 115 - }; 116 - 117 function attributesToStyle(d: Delta) { 118 let props = { 119 style: {}, ··· 144 return props; 145 } 146 147 - export function YJSFragmentToString( 148 - node: XmlElement | XmlText | XmlHook, 149 - ): string { 150 - if (node.constructor === XmlElement) { 151 - // Handle hard_break nodes specially 152 - if (node.nodeName === "hard_break") { 153 - return "\n"; 154 - } 155 - return node 156 - .toArray() 157 - .map((f) => YJSFragmentToString(f)) 158 - .join(""); 159 - } 160 - if (node.constructor === XmlText) { 161 - return (node.toDelta() as Delta[]) 162 - .map((d) => { 163 - return d.insert; 164 - }) 165 - .join(""); 166 - } 167 - return ""; 168 - }
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 + import { AtMentionLink } from "components/AtMentionLink"; 8 + import { Delta } from "src/utils/yjsFragmentToString"; 9 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 11 export function RenderYJSFragment({ ··· 67 return <br key={index} />; 68 } 69 70 + // Handle didMention inline nodes 71 + if (node.constructor === XmlElement && node.nodeName === "didMention") { 72 + const did = node.getAttribute("did") || ""; 73 + const text = node.getAttribute("text") || ""; 74 + return ( 75 + <a 76 + href={didToBlueskyUrl(did)} 77 + target="_blank" 78 + rel="noopener noreferrer" 79 + key={index} 80 + className="text-accent-contrast hover:underline cursor-pointer" 81 + > 82 + {text} 83 + </a> 84 + ); 85 + } 86 + 87 + // Handle atMention inline nodes 88 + if (node.constructor === XmlElement && node.nodeName === "atMention") { 89 + const atURI = node.getAttribute("atURI") || ""; 90 + const text = node.getAttribute("text") || ""; 91 + return ( 92 + <AtMentionLink key={index} atURI={atURI}> 93 + {text} 94 + </AtMentionLink> 95 + ); 96 + } 97 + 98 return null; 99 }) 100 )} ··· 132 } 133 }; 134 135 function attributesToStyle(d: Delta) { 136 let props = { 137 style: {}, ··· 162 return props; 163 } 164
+109 -14
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 import { useReplicache, useEntity } from "src/replicache"; 4 import { isVisible } from "src/utils/isVisible"; 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8 import { BlockProps } from "../Block"; ··· 23 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 import { DotLoader } from "components/utils/DotLoader"; 25 import { useMountProsemirror } from "./mountProsemirror"; 26 27 const HeadingStyle = { 28 1: "text-xl font-bold", ··· 183 let editorState = useEditorStates( 184 (s) => s.editorStates[props.entityID], 185 )?.editor; 186 187 let { mountRef, actionTimeout } = useMountProsemirror({ 188 props, 189 }); 190 191 return ( ··· 199 ? "blockquote pt-3" 200 : "blockquote" 201 : "" 202 - } 203 - 204 - `} 205 > 206 <pre 207 data-entityid={props.entityID} ··· 224 } 225 }} 226 onFocus={() => { 227 setTimeout(() => { 228 useUIState.getState().setSelectedBlock(props); 229 useUIState.setState(() => ({ ··· 249 ${props.className}`} 250 ref={mountRef} 251 /> 252 {editorState?.doc.textContent.length === 0 && 253 props.previousBlock === null && 254 props.nextBlock === null ? ( ··· 439 ); 440 }; 441 442 - const useMentionState = () => { 443 - const [editorState, setEditorState] = useState<EditorState | null>(null); 444 - const [mentionState, setMentionState] = useState<{ 445 - active: boolean; 446 - range: { from: number; to: number } | null; 447 - selectedMention: { handle: string; did: string } | null; 448 - }>({ active: false, range: null, selectedMention: null }); 449 - const mentionStateRef = useRef(mentionState); 450 - mentionStateRef.current = mentionState; 451 - return { mentionStateRef }; 452 };
··· 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 import { useReplicache, useEntity } from "src/replicache"; 4 import { isVisible } from "src/utils/isVisible"; 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 8 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 9 import { BlockProps } from "../Block"; ··· 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 25 import { DotLoader } from "components/utils/DotLoader"; 26 import { useMountProsemirror } from "./mountProsemirror"; 27 + import { schema } from "./schema"; 28 + 29 + import { Mention, MentionAutocomplete } from "components/Mention"; 30 + import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 32 const HeadingStyle = { 33 1: "text-xl font-bold", ··· 188 let editorState = useEditorStates( 189 (s) => s.editorStates[props.entityID], 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 199 200 let { mountRef, actionTimeout } = useMountProsemirror({ 201 props, 202 + openMentionAutocomplete, 203 }); 204 205 return ( ··· 213 ? "blockquote pt-3" 214 : "blockquote" 215 : "" 216 + }`} 217 > 218 <pre 219 data-entityid={props.entityID} ··· 236 } 237 }} 238 onFocus={() => { 239 + handleMentionOpenChange(false); 240 setTimeout(() => { 241 useUIState.getState().setSelectedBlock(props); 242 useUIState.setState(() => ({ ··· 262 ${props.className}`} 263 ref={mountRef} 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 274 {editorState?.doc.textContent.length === 0 && 275 props.previousBlock === null && 276 props.nextBlock === null ? ( ··· 461 ); 462 }; 463 464 + const useMentionState = (entityID: string) => { 465 + let view = useEditorStates((s) => s.editorStates[entityID])?.view; 466 + let viewRef = useRef(view || null); 467 + viewRef.current = view || null; 468 + 469 + const [mentionOpen, setMentionOpen] = useState(false); 470 + const [mentionCoords, setMentionCoords] = useState<{ 471 + top: number; 472 + left: number; 473 + } | null>(null); 474 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 475 + 476 + // Close autocomplete when this block is no longer focused 477 + const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 478 + useEffect(() => { 479 + if (!isFocused) { 480 + setMentionOpen(false); 481 + setMentionCoords(null); 482 + setMentionInsertPos(null); 483 + } 484 + }, [isFocused]); 485 + 486 + const openMentionAutocomplete = useCallback(() => { 487 + const view = useEditorStates.getState().editorStates[entityID]?.view; 488 + if (!view) return; 489 + 490 + // Get the position right after the @ we just inserted 491 + const pos = view.state.selection.from; 492 + setMentionInsertPos(pos); 493 + 494 + // Get coordinates for the popup relative to the positioned parent 495 + const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 + 497 + // Find the relative positioned parent container 498 + const editorEl = view.dom; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 500 + 501 + if (container) { 502 + const containerRect = container.getBoundingClientRect(); 503 + setMentionCoords({ 504 + top: coords.bottom - containerRect.top, 505 + left: coords.left - containerRect.left, 506 + }); 507 + } else { 508 + setMentionCoords({ 509 + top: coords.bottom, 510 + left: coords.left, 511 + }); 512 + } 513 + setMentionOpen(true); 514 + }, [entityID]); 515 + 516 + const handleMentionSelect = useCallback( 517 + (mention: Mention) => { 518 + const view = useEditorStates.getState().editorStates[entityID]?.view; 519 + if (!view || mentionInsertPos === null) return; 520 + 521 + // The @ is at mentionInsertPos - 1, we need to replace it with the mention 522 + const from = mentionInsertPos - 1; 523 + const to = mentionInsertPos; 524 + 525 + addMentionToEditor(mention, { from, to }, view); 526 + view.focus(); 527 + }, 528 + [entityID, mentionInsertPos], 529 + ); 530 + 531 + const handleMentionOpenChange = useCallback((open: boolean) => { 532 + setMentionOpen(open); 533 + if (!open) { 534 + setMentionCoords(null); 535 + setMentionInsertPos(null); 536 + } 537 + }, []); 538 + 539 + return { 540 + viewRef, 541 + mentionOpen, 542 + mentionCoords, 543 + openMentionAutocomplete, 544 + handleMentionSelect, 545 + handleMentionOpenChange, 546 + }; 547 };
+20
components/Blocks/TextBlock/inputRules.ts
··· 15 export const inputrules = ( 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 ) => 19 inputRules({ 20 //Strikethrough ··· 189 data: { type: "number", value: headingLevel }, 190 }); 191 return tr; 192 }), 193 ], 194 });
··· 15 export const inputrules = ( 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 19 ) => 20 inputRules({ 21 //Strikethrough ··· 190 data: { type: "number", value: headingLevel }, 191 }); 192 return tr; 193 + }), 194 + 195 + // Mention - @ at start of line, after space, or after hard break 196 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 197 + if (!openMentionAutocomplete) return null; 198 + // Schedule opening the autocomplete after the transaction is applied 199 + setTimeout(() => openMentionAutocomplete(), 0); 200 + return null; // Let the @ be inserted normally 201 + }), 202 + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 203 + new InputRule(/@$/, (state, match, start, end) => { 204 + if (!openMentionAutocomplete) return null; 205 + // Check if the character before @ is a hard break node 206 + const $pos = state.doc.resolve(start); 207 + const nodeBefore = $pos.nodeBefore; 208 + if (nodeBefore && nodeBefore.type.name === "hard_break") { 209 + setTimeout(() => openMentionAutocomplete(), 0); 210 + } 211 + return null; // Let the @ be inserted normally 212 }), 213 ], 214 });
+5 -8
components/Blocks/TextBlock/keymap.ts
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 - 28 type PropsRef = RefObject< 29 BlockProps & { 30 entity_set: { set: string }; ··· 35 propsRef: PropsRef, 36 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 um: UndoManager, 38 - multiLine?: boolean, 39 ) => 40 ({ 41 "Meta-b": toggleMark(schema.marks.strong), ··· 138 ), 139 "Shift-Backspace": backspace(propsRef, repRef), 140 Enter: (state, dispatch, view) => { 141 - if (multiLine && state.doc.content.size - state.selection.anchor > 1) 142 - return false; 143 - return um.withUndoGroup(() => 144 - enter(propsRef, repRef)(state, dispatch, view), 145 - ); 146 }, 147 "Shift-Enter": (state, dispatch, view) => { 148 // Insert a hard break
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 + import { focusPage } from "src/utils/focusPage"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 type PropsRef = RefObject< 28 BlockProps & { 29 entity_set: { set: string }; ··· 34 propsRef: PropsRef, 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 36 um: UndoManager, 37 + openMentionAutocomplete: () => void, 38 ) => 39 ({ 40 "Meta-b": toggleMark(schema.marks.strong), ··· 137 ), 138 "Shift-Backspace": backspace(propsRef, repRef), 139 Enter: (state, dispatch, view) => { 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 143 }, 144 "Shift-Enter": (state, dispatch, view) => { 145 // Insert a hard break
+48 -12
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 import { useHandlePaste } from "./useHandlePaste"; 24 import { BlockProps } from "../Block"; 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 let { entityID, parent } = props; 29 let rep = useReplicache(); 30 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 useLayoutEffect(() => { 45 if (!mountRef.current) return; 46 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 48 const editor = EditorState.create({ 49 schema: schema, 50 plugins: [ 51 ySyncPlugin(value), 52 keymap(km), 53 - inputrules(propsRef, repRef), 54 keymap(baseKeymap), 55 highlightSelectionPlugin, 56 autolink({ ··· 69 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 if (!direct) return; 71 if (node.nodeSize - 2 <= _pos) return; 72 - let mark = 73 - node 74 - .nodeAt(_pos - 1) 75 - ?.marks.find((f) => f.type === schema.marks.link) || 76 - node 77 - .nodeAt(Math.max(_pos - 2, 0)) 78 - ?.marks.find((f) => f.type === schema.marks.link); 79 - if (mark) { 80 - window.open(mark.attrs.href, "_blank"); 81 } 82 }, 83 dispatchTransaction,
··· 23 import { useHandlePaste } from "./useHandlePaste"; 24 import { BlockProps } from "../Block"; 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 27 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 35 let { entityID, parent } = props; 36 let rep = useReplicache(); 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 51 useLayoutEffect(() => { 52 if (!mountRef.current) return; 53 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 60 const editor = EditorState.create({ 61 schema: schema, 62 plugins: [ 63 ySyncPlugin(value), 64 keymap(km), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 66 keymap(baseKeymap), 67 highlightSelectionPlugin, 68 autolink({ ··· 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 if (!direct) return; 83 if (node.nodeSize - 2 <= _pos) return; 84 + 85 + // Check for marks at the clicked position 86 + const nodeAt1 = node.nodeAt(_pos - 1); 87 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 + 89 + // Check for link marks 90 + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 + if (linkMark) { 93 + window.open(linkMark.attrs.href, "_blank"); 94 + return; 95 + } 96 + 97 + // Check for didMention inline nodes 98 + if (nodeAt1?.type === schema.nodes.didMention) { 99 + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 + return; 101 + } 102 + if (nodeAt2?.type === schema.nodes.didMention) { 103 + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 + return; 105 + } 106 + 107 + // Check for atMention inline nodes 108 + if (nodeAt1?.type === schema.nodes.atMention) { 109 + const url = atUriToUrl(nodeAt1.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 112 + } 113 + if (nodeAt2?.type === schema.nodes.atMention) { 114 + const url = atUriToUrl(nodeAt2.attrs.atURI); 115 + window.open(url, "_blank", "noopener,noreferrer"); 116 + return; 117 } 118 }, 119 dispatchTransaction,
+100 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 2 import { marks } from "prosemirror-schema-basic"; 3 import { theme } from "tailwind.config"; 4 ··· 122 parseDOM: [{ tag: "br" }], 123 toDOM: () => ["br"] as const, 124 }, 125 }, 126 }; 127 export const schema = new Schema(baseSchema);
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 import { marks } from "prosemirror-schema-basic"; 4 import { theme } from "tailwind.config"; 5 ··· 123 parseDOM: [{ tag: "br" }], 124 toDOM: () => ["br"] as const, 125 }, 126 + atMention: { 127 + attrs: { 128 + atURI: {}, 129 + text: { default: "" }, 130 + }, 131 + group: "inline", 132 + inline: true, 133 + atom: true, 134 + selectable: true, 135 + draggable: true, 136 + parseDOM: [ 137 + { 138 + tag: "span.atMention", 139 + getAttrs(dom: HTMLElement) { 140 + return { 141 + atURI: dom.getAttribute("data-at-uri"), 142 + text: dom.textContent || "", 143 + }; 144 + }, 145 + }, 146 + ], 147 + toDOM(node) { 148 + // NOTE: This rendering should match the AtMentionLink component in 149 + // components/AtMentionLink.tsx. If you update one, update the other. 150 + let className = "atMention text-accent-contrast"; 151 + let aturi = new AtUri(node.attrs.atURI); 152 + if (aturi.collection === "pub.leaflet.publication") 153 + className += " font-bold"; 154 + if (aturi.collection === "pub.leaflet.document") className += " italic"; 155 + 156 + // For publications and documents, show icon 157 + if ( 158 + aturi.collection === "pub.leaflet.publication" || 159 + aturi.collection === "pub.leaflet.document" 160 + ) { 161 + return [ 162 + "span", 163 + { 164 + class: className, 165 + "data-at-uri": node.attrs.atURI, 166 + }, 167 + [ 168 + "img", 169 + { 170 + src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 + class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", 172 + alt: "", 173 + width: "16", 174 + height: "16", 175 + loading: "lazy", 176 + }, 177 + ], 178 + node.attrs.text, 179 + ]; 180 + } 181 + 182 + return [ 183 + "span", 184 + { 185 + class: className, 186 + "data-at-uri": node.attrs.atURI, 187 + }, 188 + node.attrs.text, 189 + ]; 190 + }, 191 + } as NodeSpec, 192 + didMention: { 193 + attrs: { 194 + did: {}, 195 + text: { default: "" }, 196 + }, 197 + group: "inline", 198 + inline: true, 199 + atom: true, 200 + selectable: true, 201 + draggable: true, 202 + parseDOM: [ 203 + { 204 + tag: "span.didMention", 205 + getAttrs(dom: HTMLElement) { 206 + return { 207 + did: dom.getAttribute("data-did"), 208 + text: dom.textContent || "", 209 + }; 210 + }, 211 + }, 212 + ], 213 + toDOM(node) { 214 + return [ 215 + "span", 216 + { 217 + class: "didMention text-accent-contrast", 218 + "data-did": node.attrs.did, 219 + }, 220 + node.attrs.text, 221 + ]; 222 + }, 223 + } as NodeSpec, 224 }, 225 }; 226 export const schema = new Schema(baseSchema);
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
··· 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
-17
components/EmptyState.tsx
··· 1 - export const EmptyState = (props: { 2 - children: React.ReactNode; 3 - className?: string; 4 - }) => { 5 - return ( 6 - <div 7 - className={` 8 - flex flex-col gap-2 justify-between 9 - container bg-[rgba(var(--bg-page),.7)] 10 - sm:p-4 p-3 mt-2 11 - text-center text-tertiary 12 - ${props.className}`} 13 - > 14 - {props.children} 15 - </div> 16 - ); 17 - };
···
+21
components/Icons/GoBackTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoBackTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M7.40426 3L2.19592 8M2.19592 8L7.40426 13M2.19592 8H13.8041" 14 + stroke="currentColor" 15 + strokeWidth="2" 16 + strokeLinecap="round" 17 + strokeLinejoin="round" 18 + /> 19 + </svg> 20 + ); 21 + };
+19
components/Icons/TagTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const TagTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M3.70775 9.003C3.96622 8.90595 4.25516 9.03656 4.35228 9.29499C4.37448 9.35423 4.38309 9.41497 4.38255 9.47468C4.38208 9.6765 4.25946 9.86621 4.05931 9.94148C3.36545 10.2021 2.74535 10.833 2.42747 11.5479C2.33495 11.7561 2.27242 11.9608 2.239 12.1573C2.15817 12.6374 2.25357 13.069 2.52513 13.3858C2.92043 13.8467 3.51379 14.0403 4.20189 14.0665C4.88917 14.0925 5.59892 13.9482 6.12571 13.8126C7.09158 13.5639 7.81893 13.6157 8.29954 13.9415C8.67856 14.1986 8.83462 14.578 8.8347 14.9298C8.83502 15.0506 8.81652 15.1682 8.78294 15.2764C8.7009 15.5398 8.42049 15.6873 8.15696 15.6055C7.89935 15.5253 7.75386 15.2555 7.82396 14.9971C7.82572 14.9905 7.8258 14.9833 7.82786 14.9766C7.83167 14.9643 7.834 14.9503 7.8347 14.9356C7.83623 14.8847 7.8147 14.823 7.739 14.7716C7.61179 14.6853 7.23586 14.5616 6.37474 14.7833C5.81779 14.9266 4.99695 15.1 4.1638 15.0684C3.33126 15.0368 2.41412 14.7967 1.76536 14.0401C1.30175 13.4992 1.16206 12.8427 1.22728 12.1993C1.23863 12.086 1.25554 11.9732 1.27903 11.8614C1.28235 11.8457 1.28624 11.8302 1.28978 11.8145C1.34221 11.5817 1.41832 11.3539 1.51439 11.1378C1.92539 10.2136 2.72927 9.37064 3.70775 9.003ZM13.8972 7.54695C14.124 7.38948 14.4359 7.44622 14.5935 7.67292C14.7508 7.89954 14.6948 8.21063 14.4685 8.36823L8.65892 12.4044C8.24041 12.695 7.74265 12.8515 7.23314 12.8516H3.9138C3.63794 12.8515 3.41315 12.6274 3.41282 12.3516C3.41282 12.0755 3.63769 11.8517 3.9138 11.8516H7.23216C7.538 11.8516 7.8374 11.7575 8.0886 11.5831L13.8972 7.54695ZM10.1609 0.550851C10.6142 0.235853 11.2372 0.347685 11.5525 0.800851L14.6091 5.19734C14.9239 5.65063 14.8121 6.27369 14.3591 6.58894L7.88841 11.087C7.63297 11.2645 7.32837 11.3586 7.01732 11.3555L4.1804 11.3262C3.76371 11.3218 3.38443 11.1921 3.072 10.9776C3.23822 10.7748 3.43062 10.5959 3.63646 10.4503C3.96958 10.5767 4.35782 10.5421 4.67259 10.3233C5.17899 9.97084 5.30487 9.27438 4.95286 8.76765C4.60048 8.26108 3.90304 8.13639 3.39622 8.48835C3.17656 8.64127 3.02799 8.85895 2.9597 9.09773C2.69658 9.26211 2.45194 9.45783 2.23118 9.67585C2.17892 9.38285 2.19133 9.07163 2.28294 8.76081L3.14818 5.8282C3.24483 5.50092 3.45101 5.21639 3.73118 5.02155L10.1609 0.550851ZM8.76732 3.73835L9.73607 4.91023L8.68626 5.41804L7.79466 6.24323L7.04857 4.91804L6.26634 5.45417L7.22923 6.63386L5.72337 7.40437L6.34739 8.31355L7.60814 7.18464L8.37767 8.53132L9.15989 7.99421L8.17454 6.79792L9.27708 6.25788L10.1179 5.46589L10.8786 6.81452L11.6609 6.27741L10.6745 5.07917L12.1882 4.30476L11.5642 3.39558L10.2976 4.52839L9.54954 3.20124L8.76732 3.73835Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1 -34
components/Input.tsx
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 6 export const Input = ( 7 props: { ··· 56 }} 57 /> 58 ); 59 - }; 60 - 61 - export const focusElement = (el?: HTMLInputElement | null) => { 62 - if (!isIOS()) { 63 - el?.focus(); 64 - return; 65 - } 66 - 67 - let fakeInput = document.createElement("input"); 68 - fakeInput.setAttribute("type", "text"); 69 - fakeInput.style.position = "fixed"; 70 - fakeInput.style.height = "0px"; 71 - fakeInput.style.width = "0px"; 72 - fakeInput.style.fontSize = "16px"; // disable auto zoom 73 - document.body.appendChild(fakeInput); 74 - fakeInput.focus(); 75 - setTimeout(() => { 76 - if (!el) return; 77 - el.style.transform = "translateY(-2000px)"; 78 - el?.focus(); 79 - fakeInput.remove(); 80 - el.value = " "; 81 - el.setSelectionRange(1, 1); 82 - requestAnimationFrame(() => { 83 - if (el) { 84 - el.style.transform = ""; 85 - } 86 - }); 87 - setTimeout(() => { 88 - if (!el) return; 89 - el.value = ""; 90 - el.setSelectionRange(0, 0); 91 - }, 50); 92 - }, 20); 93 }; 94 95 export const InputWithLabel = (
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 6 7 export const Input = ( 8 props: { ··· 57 }} 58 /> 59 ); 60 }; 61 62 export const InputWithLabel = (
+114
components/InteractionsPreview.tsx
···
··· 1 + "use client"; 2 + import { Separator } from "./Layout"; 3 + import { CommentTiny } from "./Icons/CommentTiny"; 4 + import { QuoteTiny } from "./Icons/QuoteTiny"; 5 + import { useSmoker } from "./Toast"; 6 + import { Tag } from "./Tags"; 7 + import { Popover } from "./Popover"; 8 + import { TagTiny } from "./Icons/TagTiny"; 9 + import { SpeedyLink } from "./SpeedyLink"; 10 + 11 + export const InteractionPreview = (props: { 12 + quotesCount: number; 13 + commentsCount: number; 14 + tags?: string[]; 15 + postUrl: string; 16 + showComments: boolean | undefined; 17 + share?: boolean; 18 + }) => { 19 + let smoker = useSmoker(); 20 + let interactionsAvailable = 21 + props.quotesCount > 0 || 22 + (props.showComments !== false && props.commentsCount > 0); 23 + 24 + const tagsCount = props.tags?.length || 0; 25 + 26 + return ( 27 + <div 28 + className={`flex gap-2 text-tertiary text-sm items-center self-start`} 29 + > 30 + {tagsCount === 0 ? null : ( 31 + <> 32 + <TagPopover tags={props.tags!} /> 33 + {interactionsAvailable || props.share ? ( 34 + <Separator classname="h-4!" /> 35 + ) : null} 36 + </> 37 + )} 38 + 39 + {props.quotesCount === 0 ? null : ( 40 + <SpeedyLink 41 + aria-label="Post quotes" 42 + href={`${props.postUrl}?interactionDrawer=quotes`} 43 + className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 44 + > 45 + <QuoteTiny /> {props.quotesCount} 46 + </SpeedyLink> 47 + )} 48 + {props.showComments === false || props.commentsCount === 0 ? null : ( 49 + <SpeedyLink 50 + aria-label="Post comments" 51 + href={`${props.postUrl}?interactionDrawer=comments`} 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 + > 54 + <CommentTiny /> {props.commentsCount} 55 + </SpeedyLink> 56 + )} 57 + {interactionsAvailable && props.share ? ( 58 + <Separator classname="h-4! !min-h-0" /> 59 + ) : null} 60 + {props.share && ( 61 + <> 62 + <button 63 + id={`copy-post-link-${props.postUrl}`} 64 + className="flex gap-1 items-center hover:text-accent-contrast relative" 65 + onClick={(e) => { 66 + e.stopPropagation(); 67 + e.preventDefault(); 68 + let mouseX = e.clientX; 69 + let mouseY = e.clientY; 70 + 71 + if (!props.postUrl) return; 72 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 73 + 74 + smoker({ 75 + text: <strong>Copied Link!</strong>, 76 + position: { 77 + y: mouseY, 78 + x: mouseX, 79 + }, 80 + }); 81 + }} 82 + > 83 + Share 84 + </button> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + const TagPopover = (props: { tags: string[] }) => { 92 + return ( 93 + <Popover 94 + className="p-2! max-w-xs" 95 + trigger={ 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 97 + <TagTiny /> {props.tags.length} 98 + </div> 99 + } 100 + > 101 + <TagList tags={props.tags} className="text-secondary!" /> 102 + </Popover> 103 + ); 104 + }; 105 + 106 + const TagList = (props: { tags: string[]; className?: string }) => { 107 + return ( 108 + <div className="flex gap-1 flex-wrap"> 109 + {props.tags.map((tag, index) => ( 110 + <Tag name={tag} key={index} className={props.className} /> 111 + ))} 112 + </div> 113 + ); 114 + };
+1 -1
components/Layout.tsx
··· 3 import { theme } from "tailwind.config"; 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover"; 7 import { useState } from "react"; 8 9 export const Separator = (props: { classname?: string }) => {
··· 3 import { theme } from "tailwind.config"; 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 import { useState } from "react"; 8 9 export const Separator = (props: { classname?: string }) => {
+543
components/Mention.tsx
···
··· 1 + "use client"; 2 + import { Agent } from "@atproto/api"; 3 + import { useState, useEffect, Fragment, useRef, useCallback } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import * as Popover from "@radix-ui/react-popover"; 6 + import { EditorView } from "prosemirror-view"; 7 + import { callRPC } from "app/api/rpc/client"; 8 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 10 + import { SearchTiny } from "components/Icons/SearchTiny"; 11 + import { CloseTiny } from "./Icons/CloseTiny"; 12 + import { GoToArrow } from "./Icons/GoToArrow"; 13 + import { GoBackTiny } from "./Icons/GoBackTiny"; 14 + 15 + export function MentionAutocomplete(props: { 16 + open: boolean; 17 + onOpenChange: (open: boolean) => void; 18 + view: React.RefObject<EditorView | null>; 19 + onSelect: (mention: Mention) => void; 20 + coords: { top: number; left: number } | null; 21 + placeholder?: string; 22 + }) { 23 + const [searchQuery, setSearchQuery] = useState(""); 24 + const [noResults, setNoResults] = useState(false); 25 + const inputRef = useRef<HTMLInputElement>(null); 26 + const contentRef = useRef<HTMLDivElement>(null); 27 + 28 + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = 29 + useMentionSuggestions(searchQuery); 30 + 31 + // Clear search when scope changes 32 + const handleScopeChange = useCallback( 33 + (newScope: MentionScope) => { 34 + setSearchQuery(""); 35 + setSuggestionIndex(0); 36 + setScope(newScope); 37 + }, 38 + [setScope, setSuggestionIndex], 39 + ); 40 + 41 + // Focus input when opened 42 + useEffect(() => { 43 + if (props.open && inputRef.current) { 44 + // Small delay to ensure the popover is mounted 45 + setTimeout(() => inputRef.current?.focus(), 0); 46 + } 47 + }, [props.open]); 48 + 49 + // Reset state when closed 50 + useEffect(() => { 51 + if (!props.open) { 52 + setSearchQuery(""); 53 + setScope({ type: "default" }); 54 + setSuggestionIndex(0); 55 + setNoResults(false); 56 + } 57 + }, [props.open, setScope, setSuggestionIndex]); 58 + 59 + // Handle timeout for showing "No results found" 60 + useEffect(() => { 61 + if (searchQuery && suggestions.length === 0) { 62 + setNoResults(false); 63 + const timer = setTimeout(() => { 64 + setNoResults(true); 65 + }, 2000); 66 + return () => clearTimeout(timer); 67 + } else { 68 + setNoResults(false); 69 + } 70 + }, [searchQuery, suggestions.length]); 71 + 72 + // Handle keyboard navigation 73 + const handleKeyDown = (e: React.KeyboardEvent) => { 74 + if (e.key === "Escape") { 75 + e.preventDefault(); 76 + props.onOpenChange(false); 77 + props.view.current?.focus(); 78 + return; 79 + } 80 + 81 + if (e.key === "Backspace" && searchQuery === "") { 82 + // Backspace at the start of input closes autocomplete and refocuses editor 83 + e.preventDefault(); 84 + props.onOpenChange(false); 85 + props.view.current?.focus(); 86 + return; 87 + } 88 + 89 + // Reverse arrow key direction when popover is rendered above 90 + const isReversed = contentRef.current?.dataset.side === "top"; 91 + const upKey = isReversed ? "ArrowDown" : "ArrowUp"; 92 + const downKey = isReversed ? "ArrowUp" : "ArrowDown"; 93 + 94 + if (e.key === upKey) { 95 + e.preventDefault(); 96 + if (suggestionIndex > 0) { 97 + setSuggestionIndex((i) => i - 1); 98 + } 99 + } else if (e.key === downKey) { 100 + e.preventDefault(); 101 + if (suggestionIndex < suggestions.length - 1) { 102 + setSuggestionIndex((i) => i + 1); 103 + } 104 + } else if (e.key === "Tab") { 105 + const selectedSuggestion = suggestions[suggestionIndex]; 106 + if (selectedSuggestion?.type === "publication") { 107 + e.preventDefault(); 108 + handleScopeChange({ 109 + type: "publication", 110 + uri: selectedSuggestion.uri, 111 + name: selectedSuggestion.name, 112 + }); 113 + } 114 + } else if (e.key === "Enter") { 115 + e.preventDefault(); 116 + const selectedSuggestion = suggestions[suggestionIndex]; 117 + if (selectedSuggestion) { 118 + props.onSelect(selectedSuggestion); 119 + props.onOpenChange(false); 120 + } 121 + } else if ( 122 + e.key === " " && 123 + searchQuery === "" && 124 + scope.type === "default" 125 + ) { 126 + // Space immediately after opening closes the autocomplete 127 + e.preventDefault(); 128 + props.onOpenChange(false); 129 + // Insert a space after the @ in the editor 130 + if (props.view.current) { 131 + const view = props.view.current; 132 + const tr = view.state.tr.insertText(" "); 133 + view.dispatch(tr); 134 + view.focus(); 135 + } 136 + } 137 + }; 138 + 139 + if (!props.open || !props.coords) return null; 140 + 141 + const getHeader = (type: Mention["type"], scope?: MentionScope) => { 142 + switch (type) { 143 + case "did": 144 + return "People"; 145 + case "publication": 146 + return "Publications"; 147 + case "post": 148 + if (scope) { 149 + return ( 150 + <ScopeHeader 151 + scope={scope} 152 + handleScopeChange={() => { 153 + handleScopeChange({ type: "default" }); 154 + }} 155 + /> 156 + ); 157 + } else return "Posts"; 158 + } 159 + }; 160 + 161 + const sortedSuggestions = [...suggestions].sort((a, b) => { 162 + const order: Mention["type"][] = ["did", "publication", "post"]; 163 + return order.indexOf(a.type) - order.indexOf(b.type); 164 + }); 165 + 166 + return ( 167 + <Popover.Root open> 168 + <Popover.Anchor 169 + style={{ 170 + top: props.coords.top - 24, 171 + left: props.coords.left, 172 + height: 24, 173 + position: "absolute", 174 + }} 175 + /> 176 + <Popover.Portal> 177 + <Popover.Content 178 + ref={contentRef} 179 + align="start" 180 + sideOffset={4} 181 + collisionPadding={32} 182 + onOpenAutoFocus={(e) => e.preventDefault()} 183 + className={`dropdownMenu group/mention-menu z-20 bg-bg-page 184 + flex data-[side=top]:flex-col-reverse flex-col 185 + p-1 gap-1 text-primary 186 + border border-border rounded-md shadow-md 187 + sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 188 + max-h-(--radix-popover-content-available-height) 189 + overflow-hidden`} 190 + > 191 + {/* Dropdown Header - sticky */} 192 + <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0"> 193 + <div className="flex items-center gap-1 flex-1 min-w-0 text-primary"> 194 + <div className="text-tertiary"> 195 + <SearchTiny className="w-4 h-4 shrink-0" /> 196 + </div> 197 + <input 198 + ref={inputRef} 199 + size={100} 200 + type="text" 201 + value={searchQuery} 202 + onChange={(e) => { 203 + setSearchQuery(e.target.value); 204 + setSuggestionIndex(0); 205 + }} 206 + onKeyDown={handleKeyDown} 207 + autoFocus 208 + placeholder={ 209 + scope.type === "publication" 210 + ? "Search posts..." 211 + : props.placeholder ?? "Search people & publications..." 212 + } 213 + className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 214 + /> 215 + </div> 216 + </div> 217 + <div className="overflow-y-auto flex-1 min-h-0"> 218 + {sortedSuggestions.length === 0 && noResults && ( 219 + <div className="text-sm text-tertiary italic px-3 py-1 text-center"> 220 + No results found 221 + </div> 222 + )} 223 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 224 + {sortedSuggestions.map((result, index) => { 225 + const prevResult = sortedSuggestions[index - 1]; 226 + const showHeader = 227 + index === 0 || 228 + (prevResult && prevResult.type !== result.type); 229 + 230 + return ( 231 + <Fragment 232 + key={result.type === "did" ? result.did : result.uri} 233 + > 234 + {showHeader && ( 235 + <> 236 + {index > 0 && ( 237 + <hr className="border-border-light mx-1 my-1" /> 238 + )} 239 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 240 + {getHeader(result.type, scope)} 241 + </div> 242 + </> 243 + )} 244 + {result.type === "did" ? ( 245 + <DidResult 246 + onClick={() => { 247 + props.onSelect(result); 248 + props.onOpenChange(false); 249 + }} 250 + onMouseDown={(e) => e.preventDefault()} 251 + displayName={result.displayName} 252 + handle={result.handle} 253 + avatar={result.avatar} 254 + selected={index === suggestionIndex} 255 + /> 256 + ) : result.type === "publication" ? ( 257 + <PublicationResult 258 + onClick={() => { 259 + props.onSelect(result); 260 + props.onOpenChange(false); 261 + }} 262 + onMouseDown={(e) => e.preventDefault()} 263 + pubName={result.name} 264 + uri={result.uri} 265 + selected={index === suggestionIndex} 266 + onPostsClick={() => { 267 + handleScopeChange({ 268 + type: "publication", 269 + uri: result.uri, 270 + name: result.name, 271 + }); 272 + }} 273 + /> 274 + ) : ( 275 + <PostResult 276 + onClick={() => { 277 + props.onSelect(result); 278 + props.onOpenChange(false); 279 + }} 280 + onMouseDown={(e) => e.preventDefault()} 281 + title={result.title} 282 + selected={index === suggestionIndex} 283 + /> 284 + )} 285 + </Fragment> 286 + ); 287 + })} 288 + </ul> 289 + </div> 290 + </Popover.Content> 291 + </Popover.Portal> 292 + </Popover.Root> 293 + ); 294 + } 295 + 296 + const Result = (props: { 297 + result: React.ReactNode; 298 + subtext?: React.ReactNode; 299 + icon?: React.ReactNode; 300 + onClick: () => void; 301 + onMouseDown: (e: React.MouseEvent) => void; 302 + selected?: boolean; 303 + }) => { 304 + return ( 305 + <button 306 + className={` 307 + menuItem w-full flex-row! gap-2! 308 + text-secondary leading-snug text-sm 309 + ${props.subtext ? "py-1!" : "py-2!"} 310 + ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} 311 + onClick={() => { 312 + props.onClick(); 313 + }} 314 + onMouseDown={(e) => props.onMouseDown(e)} 315 + > 316 + {props.icon} 317 + <div className="flex flex-col min-w-0 flex-1"> 318 + <div 319 + className={`flex gap-2 items-center w-full truncate justify-between`} 320 + > 321 + {props.result} 322 + </div> 323 + {props.subtext && ( 324 + <div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]"> 325 + {props.subtext} 326 + </div> 327 + )} 328 + </div> 329 + </button> 330 + ); 331 + }; 332 + 333 + const ScopeButton = (props: { 334 + onClick: () => void; 335 + children: React.ReactNode; 336 + }) => { 337 + return ( 338 + <span 339 + className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer" 340 + onClick={(e) => { 341 + e.preventDefault(); 342 + e.stopPropagation(); 343 + props.onClick(); 344 + }} 345 + onMouseDown={(e) => { 346 + e.preventDefault(); 347 + e.stopPropagation(); 348 + }} 349 + > 350 + {props.children} <ArrowRightTiny className="scale-80" /> 351 + </span> 352 + ); 353 + }; 354 + 355 + const DidResult = (props: { 356 + displayName?: string; 357 + handle: string; 358 + avatar?: string; 359 + onClick: () => void; 360 + onMouseDown: (e: React.MouseEvent) => void; 361 + selected?: boolean; 362 + }) => { 363 + return ( 364 + <Result 365 + icon={ 366 + props.avatar ? ( 367 + <img 368 + src={props.avatar} 369 + alt="" 370 + className="w-5 h-5 rounded-full shrink-0" 371 + /> 372 + ) : ( 373 + <div className="w-5 h-5 rounded-full bg-border shrink-0" /> 374 + ) 375 + } 376 + result={props.displayName ? props.displayName : props.handle} 377 + subtext={props.displayName && `@${props.handle}`} 378 + onClick={props.onClick} 379 + onMouseDown={props.onMouseDown} 380 + selected={props.selected} 381 + /> 382 + ); 383 + }; 384 + 385 + const PublicationResult = (props: { 386 + pubName: string; 387 + uri: string; 388 + onClick: () => void; 389 + onMouseDown: (e: React.MouseEvent) => void; 390 + selected?: boolean; 391 + onPostsClick: () => void; 392 + }) => { 393 + return ( 394 + <Result 395 + icon={ 396 + <img 397 + src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`} 398 + alt="" 399 + className="w-5 h-5 rounded-full shrink-0" 400 + /> 401 + } 402 + result={ 403 + <> 404 + <div className="truncate w-full grow min-w-0">{props.pubName}</div> 405 + <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton> 406 + </> 407 + } 408 + onClick={props.onClick} 409 + onMouseDown={props.onMouseDown} 410 + selected={props.selected} 411 + /> 412 + ); 413 + }; 414 + 415 + const PostResult = (props: { 416 + title: string; 417 + onClick: () => void; 418 + onMouseDown: (e: React.MouseEvent) => void; 419 + selected?: boolean; 420 + }) => { 421 + return ( 422 + <Result 423 + result={<div className="truncate w-full">{props.title}</div>} 424 + onClick={props.onClick} 425 + onMouseDown={props.onMouseDown} 426 + selected={props.selected} 427 + /> 428 + ); 429 + }; 430 + 431 + const ScopeHeader = (props: { 432 + scope: MentionScope; 433 + handleScopeChange: () => void; 434 + }) => { 435 + if (props.scope.type === "default") return; 436 + if (props.scope.type === "publication") 437 + return ( 438 + <button 439 + className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs" 440 + onClick={() => props.handleScopeChange()} 441 + onMouseDown={(e) => e.preventDefault()} 442 + > 443 + <GoBackTiny className="shrink-0 " /> 444 + 445 + <div className="grow w-full truncate text-left"> 446 + Posts from {props.scope.name} 447 + </div> 448 + </button> 449 + ); 450 + }; 451 + 452 + export type Mention = 453 + | { 454 + type: "did"; 455 + handle: string; 456 + did: string; 457 + displayName?: string; 458 + avatar?: string; 459 + } 460 + | { type: "publication"; uri: string; name: string; url: string } 461 + | { type: "post"; uri: string; title: string; url: string }; 462 + 463 + export type MentionScope = 464 + | { type: "default" } 465 + | { type: "publication"; uri: string; name: string }; 466 + function useMentionSuggestions(query: string | null) { 467 + const [suggestionIndex, setSuggestionIndex] = useState(0); 468 + const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 469 + const [scope, setScope] = useState<MentionScope>({ type: "default" }); 470 + 471 + // Clear suggestions immediately when scope changes 472 + const setScopeAndClear = useCallback((newScope: MentionScope) => { 473 + setSuggestions([]); 474 + setScope(newScope); 475 + }, []); 476 + 477 + useDebouncedEffect( 478 + async () => { 479 + if (!query && scope.type === "default") { 480 + setSuggestions([]); 481 + return; 482 + } 483 + 484 + if (scope.type === "publication") { 485 + // Search within the publication's documents 486 + const documents = await callRPC(`search_publication_documents`, { 487 + publication_uri: scope.uri, 488 + query: query || "", 489 + limit: 10, 490 + }); 491 + setSuggestions( 492 + documents.result.documents.map((d) => ({ 493 + type: "post" as const, 494 + uri: d.uri, 495 + title: d.title, 496 + url: d.url, 497 + })), 498 + ); 499 + } else { 500 + // Default scope: search people and publications 501 + const agent = new Agent("https://public.api.bsky.app"); 502 + const [result, publications] = await Promise.all([ 503 + agent.searchActorsTypeahead({ 504 + q: query || "", 505 + limit: 8, 506 + }), 507 + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), 508 + ]); 509 + setSuggestions([ 510 + ...result.data.actors.map((actor) => ({ 511 + type: "did" as const, 512 + handle: actor.handle, 513 + did: actor.did, 514 + displayName: actor.displayName, 515 + avatar: actor.avatar, 516 + })), 517 + ...publications.result.publications.map((p) => ({ 518 + type: "publication" as const, 519 + uri: p.uri, 520 + name: p.name, 521 + url: p.url, 522 + })), 523 + ]); 524 + } 525 + }, 526 + 300, 527 + [query, scope], 528 + ); 529 + 530 + useEffect(() => { 531 + if (suggestionIndex > suggestions.length - 1) { 532 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 533 + } 534 + }, [suggestionIndex, suggestions.length]); 535 + 536 + return { 537 + suggestions, 538 + suggestionIndex, 539 + setSuggestionIndex, 540 + scope, 541 + setScope: setScopeAndClear, 542 + }; 543 + }
+5 -2
components/PageLayouts/DashboardLayout.tsx
··· 372 ); 373 } 374 375 - const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 376 let { filter } = useDashboardState(); 377 let setState = useSetDashboardState(); 378 let filterCount = Object.values(filter).filter(Boolean).length; ··· 469 type="text" 470 id="pubName" 471 size={1} 472 - placeholder="searchโ€ฆ" 473 value={props.searchValue} 474 onChange={(e) => { 475 props.setSearchValue(e.currentTarget.value);
··· 372 ); 373 } 374 375 + const FilterOptions = (props: { 376 + hasPubs: boolean; 377 + hasArchived: boolean; 378 + }) => { 379 let { filter } = useDashboardState(); 380 let setState = useSetDashboardState(); 381 let filterCount = Object.values(filter).filter(Boolean).length; ··· 472 type="text" 473 id="pubName" 474 size={1} 475 + placeholder="search..." 476 value={props.searchValue} 477 onChange={(e) => { 478 props.setSearchValue(e.currentTarget.value);
+4 -8
components/PageSWRDataProvider.tsx
··· 90 const publishedInPublication = data.leaflets_in_publications?.find( 91 (l) => l.doc, 92 ); 93 - const publishedStandalone = 94 - data.leaflets_to_documents && data.leaflets_to_documents.documents 95 - ? data.leaflets_to_documents 96 - : null; 97 98 const documentUri = 99 publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 100 101 // Compute the full post URL for sharing 102 let postShareLink: string | undefined; 103 - if ( 104 - publishedInPublication?.publications && 105 - publishedInPublication.documents 106 - ) { 107 // Published in a publication - use publication URL + document rkey 108 const docUri = new AtUri(publishedInPublication.documents.uri); 109 postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
··· 90 const publishedInPublication = data.leaflets_in_publications?.find( 91 (l) => l.doc, 92 ); 93 + const publishedStandalone = data.leaflets_to_documents?.find( 94 + (l) => !!l.documents, 95 + ); 96 97 const documentUri = 98 publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 99 100 // Compute the full post URL for sharing 101 let postShareLink: string | undefined; 102 + if (publishedInPublication?.publications && publishedInPublication.documents) { 103 // Published in a publication - use publication URL + document rkey 104 const docUri = new AtUri(publishedInPublication.documents.uri); 105 postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+1 -1
components/Pages/Page.tsx
··· 12 import { Blocks } from "components/Blocks"; 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 16 import { PageOptions } from "./PageOptions"; 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
··· 12 import { Blocks } from "components/Blocks"; 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 + import { focusPage } from "src/utils/focusPage"; 16 import { PageOptions } from "./PageOptions"; 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+156 -84
components/Pages/PublicationMetadata.tsx
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 import { useIdentityData } from "components/IdentityProvider"; 17 export const PublicationMetadata = () => { 18 let { rep } = useReplicache(); 19 let { data: pub } = useLeafletPublicationData(); ··· 23 tx.get<string>("publication_description"), 24 ); 25 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 26 let publishedAt = record?.publishedAt; 27 28 if (!pub) return null; ··· 33 if (typeof description !== "string") { 34 description = pub?.description || ""; 35 } 36 return ( 37 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 38 - <div className="flex gap-2"> 39 - {pub.publications && ( 40 - <Link 41 - href={ 42 - identity?.atp_did === pub.publications?.identity_did 43 - ? `${getBasePublicationURL(pub.publications)}/dashboard` 44 - : getPublicationURL(pub.publications) 45 - } 46 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 47 - > 48 - {pub.publications?.name} 49 - </Link> 50 - )} 51 - <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 52 - Editor 53 - </div> 54 - </div> 55 - <TextField 56 - className="text-xl font-bold outline-hidden bg-transparent" 57 - value={title} 58 - onChange={async (newTitle) => { 59 - await rep?.mutate.updatePublicationDraft({ 60 - title: newTitle, 61 - description, 62 - }); 63 - }} 64 - placeholder="Untitled" 65 - /> 66 - <TextField 67 - placeholder="add an optional description..." 68 - className="italic text-secondary outline-hidden bg-transparent" 69 - value={description} 70 - onChange={async (newDescription) => { 71 - await rep?.mutate.updatePublicationDraft({ 72 - title, 73 - description: newDescription, 74 - }); 75 - }} 76 - /> 77 - {pub.doc ? ( 78 - <div className="flex flex-row items-center gap-2 pt-3"> 79 - <p className="text-sm text-tertiary"> 80 - Published {publishedAt && timeAgo(publishedAt)} 81 - </p> 82 - <Separator classname="h-4" /> 83 - <Link 84 - target="_blank" 85 - className="text-sm" 86 - href={ 87 - pub.publications 88 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 89 - : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 90 - } 91 - > 92 - View Post 93 - </Link> 94 </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </div> 99 ); 100 }; 101 ··· 178 if (!pub) return null; 179 180 return ( 181 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 182 - <div className="text-accent-contrast font-bold hover:no-underline"> 183 - {pub.publications?.name} 184 - </div> 185 186 - <div 187 - className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`} 188 - > 189 - {pub.title ? pub.title : "Untitled"} 190 - </div> 191 - <div className="italic text-secondary outline-hidden bg-transparent"> 192 - {pub.description} 193 - </div> 194 195 - {pub.doc ? ( 196 - <div className="flex flex-row items-center gap-2 pt-3"> 197 - <p className="text-sm text-tertiary"> 198 - Published {publishedAt && timeAgo(publishedAt)} 199 - </p> 200 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 205 ); 206 };
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { useRef, useState } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 + import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 18 + import { TagTiny } from "components/Icons/TagTiny"; 19 + import { Popover } from "components/Popover"; 20 + import { TagSelector } from "components/Tags"; 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 export const PublicationMetadata = () => { 24 let { rep } = useReplicache(); 25 let { data: pub } = useLeafletPublicationData(); ··· 29 tx.get<string>("publication_description"), 30 ); 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 35 let publishedAt = record?.publishedAt; 36 37 if (!pub) return null; ··· 42 if (typeof description !== "string") { 43 description = pub?.description || ""; 44 } 45 + let tags = true; 46 + 47 return ( 48 + <PostHeaderLayout 49 + pubLink={ 50 + <div className="flex gap-2 items-center"> 51 + {pub.publications && ( 52 + <Link 53 + href={ 54 + identity?.atp_did === pub.publications?.identity_did 55 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 56 + : getPublicationURL(pub.publications) 57 + } 58 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 59 + > 60 + {pub.publications?.name} 61 + </Link> 62 + )} 63 + <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 64 + DRAFT 65 + </div> 66 </div> 67 + } 68 + postTitle={ 69 + <TextField 70 + className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 71 + value={title} 72 + onChange={async (newTitle) => { 73 + await rep?.mutate.updatePublicationDraft({ 74 + title: newTitle, 75 + description, 76 + }); 77 + }} 78 + placeholder="Untitled" 79 + /> 80 + } 81 + postDescription={ 82 + <TextField 83 + placeholder="add an optional description..." 84 + className="pt-1 italic text-secondary outline-hidden bg-transparent" 85 + value={description} 86 + onChange={async (newDescription) => { 87 + await rep?.mutate.updatePublicationDraft({ 88 + title, 89 + description: newDescription, 90 + }); 91 + }} 92 + /> 93 + } 94 + postInfo={ 95 + <> 96 + {pub.doc ? ( 97 + <div className="flex gap-2 items-center"> 98 + <p className="text-sm text-tertiary"> 99 + Published {publishedAt && timeAgo(publishedAt)} 100 + </p> 101 + 102 + <Link 103 + target="_blank" 104 + className="text-sm" 105 + href={ 106 + pub.publications 107 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 108 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 109 + } 110 + > 111 + View 112 + </Link> 113 + </div> 114 + ) : ( 115 + <p>Draft</p> 116 + )} 117 + <div className="flex gap-2 text-border items-center"> 118 + {tags && ( 119 + <> 120 + <AddTags /> 121 + <Separator classname="h-4!" /> 122 + </> 123 + )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />โ€” 126 + </div> 127 + {pubRecord?.preferences?.showComments && ( 128 + <div className="flex gap-1 items-center"> 129 + <CommentTiny />โ€” 130 + </div> 131 + )} 132 + </div> 133 + </> 134 + } 135 + /> 136 ); 137 }; 138 ··· 215 if (!pub) return null; 216 217 return ( 218 + <PostHeaderLayout 219 + pubLink={ 220 + <div className="text-accent-contrast font-bold hover:no-underline"> 221 + {pub.publications?.name} 222 + </div> 223 + } 224 + postTitle={pub.title} 225 + postDescription={pub.description} 226 + postInfo={ 227 + pub.doc ? ( 228 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 229 + ) : ( 230 + <p>Draft</p> 231 + ) 232 + } 233 + /> 234 + ); 235 + }; 236 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 241 242 + // Get tags from Replicache local state or published document 243 + let replicacheTags = useSubscribe(rep, (tx) => 244 + tx.get<string[]>("publication_tags"), 245 + ); 246 + 247 + // Determine which tags to use - prioritize Replicache state 248 + let tags: string[] = []; 249 + if (Array.isArray(replicacheTags)) { 250 + tags = replicacheTags; 251 + } else if (record?.tags && Array.isArray(record.tags)) { 252 + tags = record.tags as string[]; 253 + } 254 + 255 + // Update tags in replicache local state 256 + const handleTagsChange = async (newTags: string[]) => { 257 + // Store tags in replicache for next publish/update 258 + await rep?.mutate.updatePublicationDraft({ 259 + tags: newTags, 260 + }); 261 + }; 262 + 263 + return ( 264 + <Popover 265 + className="p-2! w-full min-w-xs" 266 + trigger={ 267 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 268 + <TagTiny />{" "} 269 + {tags.length > 0 270 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 271 + : "Add Tags"} 272 </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 277 ); 278 };
+2 -75
components/Pages/index.tsx
··· 4 import { useUIState } from "src/useUIState"; 5 import { useSearchParams } from "next/navigation"; 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 9 10 - import { Replicache } from "replicache"; 11 - import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; 12 - 13 - import { scanIndex } from "src/replicache/utils"; 14 - import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 15 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 16 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 ); 63 } 64 65 - export async function focusPage( 66 - pageID: string, 67 - rep: Replicache<ReplicacheMutators>, 68 - focusFirstBlock?: "focusFirstBlock", 69 - ) { 70 - // if this page is already focused, 71 - let focusedBlock = useUIState.getState().focusedEntity; 72 - // else set this page as focused 73 - useUIState.setState(() => ({ 74 - focusedEntity: { 75 - entityType: "page", 76 - entityID: pageID, 77 - }, 78 - })); 79 - 80 - setTimeout(async () => { 81 - //scroll to page 82 - 83 - scrollIntoViewIfNeeded( 84 - document.getElementById(elementId.page(pageID).container), 85 - false, 86 - "smooth", 87 - ); 88 - 89 - // if we asked that the function focus the first block, focus the first block 90 - if (focusFirstBlock === "focusFirstBlock") { 91 - let firstBlock = await rep.query(async (tx) => { 92 - let type = await scanIndex(tx).eav(pageID, "page/type"); 93 - let blocks = await scanIndex(tx).eav( 94 - pageID, 95 - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 96 - ); 97 - 98 - let firstBlock = blocks[0]; 99 - 100 - if (!firstBlock) { 101 - return null; 102 - } 103 - 104 - let blockType = ( 105 - await tx 106 - .scan< 107 - Fact<"block/type"> 108 - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 109 - .toArray() 110 - )[0]; 111 - 112 - if (!blockType) return null; 113 - 114 - return { 115 - value: firstBlock.data.value, 116 - type: blockType.data.value, 117 - parent: firstBlock.entity, 118 - position: firstBlock.data.position, 119 - }; 120 - }); 121 - 122 - if (firstBlock) { 123 - setTimeout(() => { 124 - focusBlock(firstBlock, { type: "start" }); 125 - }, 500); 126 - } 127 - } 128 - }, 50); 129 - } 130 - 131 - export const blurPage = () => { 132 useUIState.setState(() => ({ 133 focusedEntity: null, 134 selectedBlocks: [],
··· 4 import { useUIState } from "src/useUIState"; 5 import { useSearchParams } from "next/navigation"; 6 7 + import { useEntity } from "src/replicache"; 8 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 55 ); 56 } 57 58 + const blurPage = () => { 59 useUIState.setState(() => ({ 60 focusedEntity: null, 61 selectedBlocks: [],
+3
components/Popover/PopoverContext.ts
···
··· 1 + import { createContext } from "react"; 2 + 3 + export const PopoverOpenContext = createContext(false);
+87
components/Popover/index.tsx
···
··· 1 + "use client"; 2 + import * as RadixPopover from "@radix-ui/react-popover"; 3 + import { theme } from "tailwind.config"; 4 + import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider"; 5 + import { useEffect, useState } from "react"; 6 + import { PopoverArrow } from "../Icons/PopoverArrow"; 7 + import { PopoverOpenContext } from "./PopoverContext"; 8 + export const Popover = (props: { 9 + trigger: React.ReactNode; 10 + disabled?: boolean; 11 + children: React.ReactNode; 12 + align?: "start" | "end" | "center"; 13 + side?: "top" | "bottom" | "left" | "right"; 14 + sideOffset?: number; 15 + background?: string; 16 + border?: string; 17 + className?: string; 18 + open?: boolean; 19 + onOpenChange?: (open: boolean) => void; 20 + onOpenAutoFocus?: (e: Event) => void; 21 + asChild?: boolean; 22 + arrowFill?: string; 23 + noArrow?: boolean; 24 + }) => { 25 + let [open, setOpen] = useState(props.open || false); 26 + useEffect(() => { 27 + if (props.open !== undefined) setOpen(props.open); 28 + }, [props.open]); 29 + return ( 30 + <RadixPopover.Root 31 + open={props.open} 32 + onOpenChange={(o) => { 33 + setOpen(o); 34 + props.onOpenChange?.(o); 35 + }} 36 + > 37 + <PopoverOpenContext value={open}> 38 + <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 39 + {props.trigger} 40 + </RadixPopover.Trigger> 41 + <RadixPopover.Portal> 42 + <NestedCardThemeProvider> 43 + <RadixPopover.Content 44 + className={` 45 + z-20 bg-bg-page 46 + px-3 py-2 47 + max-w-(--radix-popover-content-available-width) 48 + max-h-(--radix-popover-content-available-height) 49 + border border-border rounded-md shadow-md 50 + overflow-y-scroll 51 + ${props.className} 52 + `} 53 + side={props.side} 54 + align={props.align ? props.align : "center"} 55 + sideOffset={props.sideOffset ? props.sideOffset : 4} 56 + collisionPadding={16} 57 + onOpenAutoFocus={props.onOpenAutoFocus} 58 + > 59 + {props.children} 60 + {!props.noArrow && ( 61 + <RadixPopover.Arrow 62 + asChild 63 + width={16} 64 + height={8} 65 + viewBox="0 0 16 8" 66 + > 67 + <PopoverArrow 68 + arrowFill={ 69 + props.arrowFill 70 + ? props.arrowFill 71 + : props.background 72 + ? props.background 73 + : theme.colors["bg-page"] 74 + } 75 + arrowStroke={ 76 + props.border ? props.border : theme.colors["border"] 77 + } 78 + /> 79 + </RadixPopover.Arrow> 80 + )} 81 + </RadixPopover.Content> 82 + </NestedCardThemeProvider> 83 + </RadixPopover.Portal> 84 + </PopoverOpenContext> 85 + </RadixPopover.Root> 86 + ); 87 + };
-84
components/Popover.tsx
··· 1 - "use client"; 2 - import * as RadixPopover from "@radix-ui/react-popover"; 3 - import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { createContext, useEffect, useState } from "react"; 6 - import { PopoverArrow } from "./Icons/PopoverArrow"; 7 - 8 - export const PopoverOpenContext = createContext(false); 9 - export const Popover = (props: { 10 - trigger: React.ReactNode; 11 - disabled?: boolean; 12 - children: React.ReactNode; 13 - align?: "start" | "end" | "center"; 14 - side?: "top" | "bottom" | "left" | "right"; 15 - background?: string; 16 - border?: string; 17 - className?: string; 18 - open?: boolean; 19 - onOpenChange?: (open: boolean) => void; 20 - onOpenAutoFocus?: (e: Event) => void; 21 - asChild?: boolean; 22 - arrowFill?: string; 23 - }) => { 24 - let [open, setOpen] = useState(props.open || false); 25 - useEffect(() => { 26 - if (props.open !== undefined) setOpen(props.open); 27 - }, [props.open]); 28 - return ( 29 - <RadixPopover.Root 30 - open={props.open} 31 - onOpenChange={(o) => { 32 - setOpen(o); 33 - props.onOpenChange?.(o); 34 - }} 35 - > 36 - <PopoverOpenContext value={open}> 37 - <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 38 - {props.trigger} 39 - </RadixPopover.Trigger> 40 - <RadixPopover.Portal> 41 - <NestedCardThemeProvider> 42 - <RadixPopover.Content 43 - className={` 44 - z-20 bg-bg-page 45 - px-3 py-2 46 - max-w-(--radix-popover-content-available-width) 47 - max-h-(--radix-popover-content-available-height) 48 - border border-border rounded-md shadow-md 49 - overflow-y-scroll 50 - ${props.className} 51 - `} 52 - side={props.side} 53 - align={props.align ? props.align : "center"} 54 - sideOffset={4} 55 - collisionPadding={16} 56 - onOpenAutoFocus={props.onOpenAutoFocus} 57 - > 58 - {props.children} 59 - <RadixPopover.Arrow 60 - asChild 61 - width={16} 62 - height={8} 63 - viewBox="0 0 16 8" 64 - > 65 - <PopoverArrow 66 - arrowFill={ 67 - props.arrowFill 68 - ? props.arrowFill 69 - : props.background 70 - ? props.background 71 - : theme.colors["bg-page"] 72 - } 73 - arrowStroke={ 74 - props.border ? props.border : theme.colors["border"] 75 - } 76 - /> 77 - </RadixPopover.Arrow> 78 - </RadixPopover.Content> 79 - </NestedCardThemeProvider> 80 - </RadixPopover.Portal> 81 - </PopoverOpenContext> 82 - </RadixPopover.Root> 83 - ); 84 - };
···
+132
components/PostListing.tsx
···
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { PubIcon } from "components/ActionBar/Publications"; 4 + import { CommentTiny } from "components/Icons/CommentTiny"; 5 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 6 + import { Separator } from "components/Layout"; 7 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 + import { useSmoker } from "components/Toast"; 10 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 + 14 + import Link from "next/link"; 15 + import { InteractionPreview } from "./InteractionsPreview"; 16 + 17 + export const PostListing = (props: Post) => { 18 + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 19 + 20 + let postRecord = props.documents.data as PubLeafletDocument.Record; 21 + let postUri = new AtUri(props.documents.uri); 22 + 23 + let theme = usePubTheme(pubRecord.theme); 24 + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 25 + ? blobRefToSrc( 26 + pubRecord?.theme?.backgroundImage?.image?.ref, 27 + new AtUri(props.publication.uri).host, 28 + ) 29 + : null; 30 + 31 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 32 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 33 + 34 + let showPageBackground = pubRecord.theme?.showPageBackground; 35 + 36 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 37 + let comments = 38 + pubRecord.preferences?.showComments === false 39 + ? 0 40 + : props.documents.comments_on_documents?.[0]?.count || 0; 41 + let tags = (postRecord?.tags as string[] | undefined) || []; 42 + 43 + return ( 44 + <BaseThemeProvider {...theme} local> 45 + <div 46 + style={{ 47 + backgroundImage: `url(${backgroundImage})`, 48 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 49 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 50 + }} 51 + className={`no-underline! flex flex-row gap-2 w-full relative 52 + bg-bg-leaflet 53 + border border-border-light rounded-lg 54 + sm:p-2 p-2 selected-outline 55 + hover:outline-accent-contrast hover:border-accent-contrast 56 + `} 57 + > 58 + <Link 59 + className="h-full w-full absolute top-0 left-0" 60 + href={`${props.publication.href}/${postUri.rkey}`} 61 + /> 62 + <div 63 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 64 + style={{ 65 + backgroundColor: showPageBackground 66 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 67 + : "transparent", 68 + }} 69 + > 70 + <h3 className="text-primary truncate">{postRecord.title}</h3> 71 + 72 + <p className="text-secondary italic">{postRecord.description}</p> 73 + <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 74 + <PubInfo 75 + href={props.publication.href} 76 + pubRecord={pubRecord} 77 + uri={props.publication.uri} 78 + /> 79 + <div className="flex flex-row justify-between gap-2 items-center w-full"> 80 + <PostInfo publishedAt={postRecord.publishedAt} /> 81 + <InteractionPreview 82 + postUrl={`${props.publication.href}/${postUri.rkey}`} 83 + quotesCount={quotes} 84 + commentsCount={comments} 85 + tags={tags} 86 + showComments={pubRecord.preferences?.showComments} 87 + share 88 + /> 89 + </div> 90 + </div> 91 + </div> 92 + </div> 93 + </BaseThemeProvider> 94 + ); 95 + }; 96 + 97 + const PubInfo = (props: { 98 + href: string; 99 + pubRecord: PubLeafletPublication.Record; 100 + uri: string; 101 + }) => { 102 + return ( 103 + <div className="flex flex-col md:w-auto shrink-0 w-full"> 104 + <hr className="md:hidden block border-border-light mb-2" /> 105 + <Link 106 + href={props.href} 107 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 108 + > 109 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 110 + {props.pubRecord.name} 111 + </Link> 112 + </div> 113 + ); 114 + }; 115 + 116 + const PostInfo = (props: { publishedAt: string | undefined }) => { 117 + return ( 118 + <div className="flex gap-2 items-center shrink-0 self-start"> 119 + {props.publishedAt && ( 120 + <> 121 + <div className="shrink-0"> 122 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 123 + year: "numeric", 124 + month: "short", 125 + day: "numeric", 126 + })} 127 + </div> 128 + </> 129 + )} 130 + </div> 131 + ); 132 + };
+717
components/SelectionManager/index.tsx
···
··· 1 + "use client"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { scanIndex } from "src/replicache/utils"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + import { useEditorStates } from "src/state/useEditorState"; 8 + import { useEntitySetContext } from "../EntitySetProvider"; 9 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 + import { indent, outdent, outdentFull } from "src/utils/list-operations"; 11 + import { addShortcut, Shortcut } from "src/shortcuts"; 12 + import { elementId } from "src/utils/elementId"; 13 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 14 + import { copySelection } from "src/utils/copySelection"; 15 + import { useIsMobile } from "src/hooks/isMobile"; 16 + import { deleteBlock } from "src/utils/deleteBlock"; 17 + import { schema } from "../Blocks/TextBlock/schema"; 18 + import { MarkType } from "prosemirror-model"; 19 + import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + 21 + //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 + // How does this relate to *when dragging* ? 23 + 24 + export function SelectionManager() { 25 + let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 26 + let entity_set = useEntitySetContext(); 27 + let { rep, undoManager } = useReplicache(); 28 + let isMobile = useIsMobile(); 29 + useEffect(() => { 30 + if (!entity_set.permissions.write || !rep) return; 31 + const getSortedSelectionBound = getSortedSelection.bind(null, rep); 32 + let shortcuts: Shortcut[] = [ 33 + { 34 + metaKey: true, 35 + key: "ArrowUp", 36 + handler: async () => { 37 + let [firstBlock] = 38 + (await rep?.query((tx) => 39 + getBlocksWithType( 40 + tx, 41 + useUIState.getState().selectedBlocks[0].parent, 42 + ), 43 + )) || []; 44 + if (firstBlock) focusBlock(firstBlock, { type: "start" }); 45 + }, 46 + }, 47 + { 48 + metaKey: true, 49 + key: "ArrowDown", 50 + handler: async () => { 51 + let blocks = 52 + (await rep?.query((tx) => 53 + getBlocksWithType( 54 + tx, 55 + useUIState.getState().selectedBlocks[0].parent, 56 + ), 57 + )) || []; 58 + let folded = useUIState.getState().foldedBlocks; 59 + blocks = blocks.filter( 60 + (f) => 61 + !f.listData || 62 + !f.listData.path.find( 63 + (path) => 64 + folded.includes(path.entity) && f.value !== path.entity, 65 + ), 66 + ); 67 + let lastBlock = blocks[blocks.length - 1]; 68 + if (lastBlock) focusBlock(lastBlock, { type: "end" }); 69 + }, 70 + }, 71 + { 72 + metaKey: true, 73 + altKey: true, 74 + key: ["l", "ยฌ"], 75 + handler: async () => { 76 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 77 + for (let block of sortedBlocks) { 78 + if (!block.listData) { 79 + await rep?.mutate.assertFact({ 80 + entity: block.value, 81 + attribute: "block/is-list", 82 + data: { type: "boolean", value: true }, 83 + }); 84 + } else { 85 + outdentFull(block, rep); 86 + } 87 + } 88 + }, 89 + }, 90 + { 91 + metaKey: true, 92 + shift: true, 93 + key: ["ArrowDown", "J"], 94 + handler: async () => { 95 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 96 + let block = sortedBlocks[0]; 97 + let nextBlock = siblings 98 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 99 + .find( 100 + (f) => 101 + f.listData && 102 + block.listData && 103 + !f.listData.path.find((f) => f.entity === block.value), 104 + ); 105 + if ( 106 + nextBlock?.listData && 107 + block.listData && 108 + nextBlock.listData.depth === block.listData.depth - 1 109 + ) { 110 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 111 + useUIState.getState().toggleFold(nextBlock.value); 112 + await rep?.mutate.moveBlock({ 113 + block: block.value, 114 + oldParent: block.listData?.parent, 115 + newParent: nextBlock.value, 116 + position: { type: "first" }, 117 + }); 118 + } else { 119 + await rep?.mutate.moveBlockDown({ 120 + entityID: block.value, 121 + parent: block.listData?.parent || block.parent, 122 + }); 123 + } 124 + }, 125 + }, 126 + { 127 + metaKey: true, 128 + shift: true, 129 + key: ["ArrowUp", "K"], 130 + handler: async () => { 131 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 132 + let block = sortedBlocks[0]; 133 + let previousBlock = 134 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 135 + if (previousBlock.value === block.listData?.parent) { 136 + previousBlock = 137 + siblings?.[ 138 + siblings.findIndex((s) => s.value === block.value) - 2 139 + ]; 140 + } 141 + 142 + if ( 143 + previousBlock?.listData && 144 + block.listData && 145 + block.listData.depth > 1 && 146 + !previousBlock.listData.path.find( 147 + (f) => f.entity === block.listData?.parent, 148 + ) 149 + ) { 150 + let depth = block.listData.depth; 151 + let newParent = previousBlock.listData.path.find( 152 + (f) => f.depth === depth - 1, 153 + ); 154 + if (!newParent) return; 155 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 156 + useUIState.getState().toggleFold(newParent.entity); 157 + rep?.mutate.moveBlock({ 158 + block: block.value, 159 + oldParent: block.listData?.parent, 160 + newParent: newParent.entity, 161 + position: { type: "end" }, 162 + }); 163 + } else { 164 + rep?.mutate.moveBlockUp({ 165 + entityID: block.value, 166 + parent: block.listData?.parent || block.parent, 167 + }); 168 + } 169 + }, 170 + }, 171 + 172 + { 173 + metaKey: true, 174 + shift: true, 175 + key: "Enter", 176 + handler: async () => { 177 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 178 + if (!sortedBlocks[0].listData) return; 179 + useUIState.getState().toggleFold(sortedBlocks[0].value); 180 + }, 181 + }, 182 + ]; 183 + if (moreThanOneSelected) 184 + shortcuts = shortcuts.concat([ 185 + { 186 + metaKey: true, 187 + key: "u", 188 + handler: async () => { 189 + let [sortedBlocks] = await getSortedSelectionBound(); 190 + toggleMarkInBlocks( 191 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 192 + schema.marks.underline, 193 + ); 194 + }, 195 + }, 196 + { 197 + metaKey: true, 198 + key: "i", 199 + handler: async () => { 200 + let [sortedBlocks] = await getSortedSelectionBound(); 201 + toggleMarkInBlocks( 202 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 203 + schema.marks.em, 204 + ); 205 + }, 206 + }, 207 + { 208 + metaKey: true, 209 + key: "b", 210 + handler: async () => { 211 + let [sortedBlocks] = await getSortedSelectionBound(); 212 + toggleMarkInBlocks( 213 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 214 + schema.marks.strong, 215 + ); 216 + }, 217 + }, 218 + { 219 + metaAndCtrl: true, 220 + key: "h", 221 + handler: async () => { 222 + let [sortedBlocks] = await getSortedSelectionBound(); 223 + toggleMarkInBlocks( 224 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 225 + schema.marks.highlight, 226 + { 227 + color: useUIState.getState().lastUsedHighlight, 228 + }, 229 + ); 230 + }, 231 + }, 232 + { 233 + metaAndCtrl: true, 234 + key: "x", 235 + handler: async () => { 236 + let [sortedBlocks] = await getSortedSelectionBound(); 237 + toggleMarkInBlocks( 238 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 239 + schema.marks.strikethrough, 240 + ); 241 + }, 242 + }, 243 + ]); 244 + let removeListener = addShortcut( 245 + shortcuts.map((shortcut) => ({ 246 + ...shortcut, 247 + handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 248 + })), 249 + ); 250 + let listener = async (e: KeyboardEvent) => 251 + undoManager.withUndoGroup(async () => { 252 + //used here and in cut 253 + const deleteBlocks = async () => { 254 + if (!entity_set.permissions.write) return; 255 + if (moreThanOneSelected) { 256 + e.preventDefault(); 257 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 258 + let selectedBlocks = useUIState.getState().selectedBlocks; 259 + let firstBlock = sortedBlocks[0]; 260 + 261 + await rep?.mutate.removeBlock( 262 + selectedBlocks.map((block) => ({ blockEntity: block.value })), 263 + ); 264 + useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 265 + 266 + let nextBlock = 267 + siblings?.[ 268 + siblings.findIndex((s) => s.value === firstBlock.value) - 1 269 + ]; 270 + if (nextBlock) { 271 + useUIState.getState().setSelectedBlock({ 272 + value: nextBlock.value, 273 + parent: nextBlock.parent, 274 + }); 275 + let type = await rep?.query((tx) => 276 + scanIndex(tx).eav(nextBlock.value, "block/type"), 277 + ); 278 + if (!type?.[0]) return; 279 + if ( 280 + type[0]?.data.value === "text" || 281 + type[0]?.data.value === "heading" 282 + ) 283 + focusBlock( 284 + { 285 + value: nextBlock.value, 286 + type: "text", 287 + parent: nextBlock.parent, 288 + }, 289 + { type: "end" }, 290 + ); 291 + } 292 + } 293 + }; 294 + if (e.key === "Backspace" || e.key === "Delete") { 295 + deleteBlocks(); 296 + } 297 + if (e.key === "ArrowUp") { 298 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 299 + let focusedBlock = useUIState.getState().focusedEntity; 300 + if (!e.shiftKey && !e.ctrlKey) { 301 + if (e.defaultPrevented) return; 302 + if (sortedBlocks.length === 1) return; 303 + let firstBlock = sortedBlocks[0]; 304 + if (!firstBlock) return; 305 + let type = await rep?.query((tx) => 306 + scanIndex(tx).eav(firstBlock.value, "block/type"), 307 + ); 308 + if (!type?.[0]) return; 309 + useUIState.getState().setSelectedBlock(firstBlock); 310 + focusBlock( 311 + { ...firstBlock, type: type[0].data.value }, 312 + { type: "start" }, 313 + ); 314 + } else { 315 + if (e.defaultPrevented) return; 316 + if ( 317 + sortedBlocks.length <= 1 || 318 + !focusedBlock || 319 + focusedBlock.entityType === "page" 320 + ) 321 + return; 322 + let b = focusedBlock; 323 + let focusedBlockIndex = sortedBlocks.findIndex( 324 + (s) => s.value == b.entityID, 325 + ); 326 + if (focusedBlockIndex === 0) { 327 + let index = siblings.findIndex((s) => s.value === b.entityID); 328 + let nextSelectedBlock = siblings[index - 1]; 329 + if (!nextSelectedBlock) return; 330 + 331 + scrollIntoViewIfNeeded( 332 + document.getElementById( 333 + elementId.block(nextSelectedBlock.value).container, 334 + ), 335 + false, 336 + ); 337 + useUIState.getState().addBlockToSelection({ 338 + ...nextSelectedBlock, 339 + }); 340 + useUIState.getState().setFocusedBlock({ 341 + entityType: "block", 342 + parent: nextSelectedBlock.parent, 343 + entityID: nextSelectedBlock.value, 344 + }); 345 + } else { 346 + let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 347 + useUIState.getState().setFocusedBlock({ 348 + entityType: "block", 349 + parent: b.parent, 350 + entityID: nextBlock.value, 351 + }); 352 + scrollIntoViewIfNeeded( 353 + document.getElementById( 354 + elementId.block(nextBlock.value).container, 355 + ), 356 + false, 357 + ); 358 + if (sortedBlocks.length === 2) { 359 + useEditorStates 360 + .getState() 361 + .editorStates[nextBlock.value]?.view?.focus(); 362 + } 363 + useUIState 364 + .getState() 365 + .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 366 + } 367 + } 368 + } 369 + if (e.key === "ArrowLeft") { 370 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 371 + if (sortedSelection.length === 1) return; 372 + let firstBlock = sortedSelection[0]; 373 + if (!firstBlock) return; 374 + let type = await rep?.query((tx) => 375 + scanIndex(tx).eav(firstBlock.value, "block/type"), 376 + ); 377 + if (!type?.[0]) return; 378 + useUIState.getState().setSelectedBlock(firstBlock); 379 + focusBlock( 380 + { ...firstBlock, type: type[0].data.value }, 381 + { type: "start" }, 382 + ); 383 + } 384 + if (e.key === "ArrowRight") { 385 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 386 + if (sortedSelection.length === 1) return; 387 + let lastBlock = sortedSelection[sortedSelection.length - 1]; 388 + if (!lastBlock) return; 389 + let type = await rep?.query((tx) => 390 + scanIndex(tx).eav(lastBlock.value, "block/type"), 391 + ); 392 + if (!type?.[0]) return; 393 + useUIState.getState().setSelectedBlock(lastBlock); 394 + focusBlock( 395 + { ...lastBlock, type: type[0].data.value }, 396 + { type: "end" }, 397 + ); 398 + } 399 + if (e.key === "Tab") { 400 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 401 + if (sortedSelection.length <= 1) return; 402 + e.preventDefault(); 403 + if (e.shiftKey) { 404 + for (let i = siblings.length - 1; i >= 0; i--) { 405 + let block = siblings[i]; 406 + if (!sortedSelection.find((s) => s.value === block.value)) 407 + continue; 408 + if ( 409 + sortedSelection.find((s) => s.value === block.listData?.parent) 410 + ) 411 + continue; 412 + let parentoffset = 1; 413 + let previousBlock = siblings[i - parentoffset]; 414 + while ( 415 + previousBlock && 416 + sortedSelection.find((s) => previousBlock.value === s.value) 417 + ) { 418 + parentoffset += 1; 419 + previousBlock = siblings[i - parentoffset]; 420 + } 421 + if (!block.listData || !previousBlock.listData) continue; 422 + outdent(block, previousBlock, rep); 423 + } 424 + } else { 425 + for (let i = 0; i < siblings.length; i++) { 426 + let block = siblings[i]; 427 + if (!sortedSelection.find((s) => s.value === block.value)) 428 + continue; 429 + if ( 430 + sortedSelection.find((s) => s.value === block.listData?.parent) 431 + ) 432 + continue; 433 + let parentoffset = 1; 434 + let previousBlock = siblings[i - parentoffset]; 435 + while ( 436 + previousBlock && 437 + sortedSelection.find((s) => previousBlock.value === s.value) 438 + ) { 439 + parentoffset += 1; 440 + previousBlock = siblings[i - parentoffset]; 441 + } 442 + if (!block.listData || !previousBlock.listData) continue; 443 + indent(block, previousBlock, rep); 444 + } 445 + } 446 + } 447 + if (e.key === "ArrowDown") { 448 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 449 + let focusedBlock = useUIState.getState().focusedEntity; 450 + if (!e.shiftKey) { 451 + if (sortedSelection.length === 1) return; 452 + let lastBlock = sortedSelection[sortedSelection.length - 1]; 453 + if (!lastBlock) return; 454 + let type = await rep?.query((tx) => 455 + scanIndex(tx).eav(lastBlock.value, "block/type"), 456 + ); 457 + if (!type?.[0]) return; 458 + useUIState.getState().setSelectedBlock(lastBlock); 459 + focusBlock( 460 + { ...lastBlock, type: type[0].data.value }, 461 + { type: "end" }, 462 + ); 463 + } 464 + if (e.shiftKey) { 465 + if (e.defaultPrevented) return; 466 + if ( 467 + sortedSelection.length <= 1 || 468 + !focusedBlock || 469 + focusedBlock.entityType === "page" 470 + ) 471 + return; 472 + let b = focusedBlock; 473 + let focusedBlockIndex = sortedSelection.findIndex( 474 + (s) => s.value == b.entityID, 475 + ); 476 + if (focusedBlockIndex === sortedSelection.length - 1) { 477 + let index = siblings.findIndex((s) => s.value === b.entityID); 478 + let nextSelectedBlock = siblings[index + 1]; 479 + if (!nextSelectedBlock) return; 480 + useUIState.getState().addBlockToSelection({ 481 + ...nextSelectedBlock, 482 + }); 483 + 484 + scrollIntoViewIfNeeded( 485 + document.getElementById( 486 + elementId.block(nextSelectedBlock.value).container, 487 + ), 488 + false, 489 + ); 490 + useUIState.getState().setFocusedBlock({ 491 + entityType: "block", 492 + parent: nextSelectedBlock.parent, 493 + entityID: nextSelectedBlock.value, 494 + }); 495 + } else { 496 + let nextBlock = sortedSelection[1]; 497 + useUIState 498 + .getState() 499 + .removeBlockFromSelection({ value: b.entityID }); 500 + scrollIntoViewIfNeeded( 501 + document.getElementById( 502 + elementId.block(nextBlock.value).container, 503 + ), 504 + false, 505 + ); 506 + useUIState.getState().setFocusedBlock({ 507 + entityType: "block", 508 + parent: b.parent, 509 + entityID: nextBlock.value, 510 + }); 511 + if (sortedSelection.length === 2) { 512 + useEditorStates 513 + .getState() 514 + .editorStates[nextBlock.value]?.view?.focus(); 515 + } 516 + } 517 + } 518 + } 519 + if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 520 + if (!rep) return; 521 + if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 522 + let [, , selectionWithFoldedChildren] = 523 + await getSortedSelectionBound(); 524 + if (!selectionWithFoldedChildren) return; 525 + let el = document.activeElement as HTMLElement; 526 + if ( 527 + el?.tagName === "LABEL" || 528 + el?.tagName === "INPUT" || 529 + el?.tagName === "TEXTAREA" 530 + ) { 531 + return; 532 + } 533 + 534 + if ( 535 + el.contentEditable === "true" && 536 + selectionWithFoldedChildren.length <= 1 537 + ) 538 + return; 539 + e.preventDefault(); 540 + await copySelection(rep, selectionWithFoldedChildren); 541 + if (e.key === "x") deleteBlocks(); 542 + } 543 + }); 544 + window.addEventListener("keydown", listener); 545 + return () => { 546 + removeListener(); 547 + window.removeEventListener("keydown", listener); 548 + }; 549 + }, [moreThanOneSelected, rep, entity_set.permissions.write]); 550 + 551 + let [mouseDown, setMouseDown] = useState(false); 552 + let initialContentEditableParent = useRef<null | Node>(null); 553 + let savedSelection = useRef<SavedRange[] | null>(undefined); 554 + useEffect(() => { 555 + if (isMobile) return; 556 + if (!entity_set.permissions.write) return; 557 + let mouseDownListener = (e: MouseEvent) => { 558 + if ((e.target as Element).getAttribute("data-draggable")) return; 559 + let contentEditableParent = getContentEditableParent(e.target as Node); 560 + if (contentEditableParent) { 561 + setMouseDown(true); 562 + let entityID = (contentEditableParent as Element).getAttribute( 563 + "data-entityid", 564 + ); 565 + useSelectingMouse.setState({ start: entityID }); 566 + } 567 + initialContentEditableParent.current = contentEditableParent; 568 + }; 569 + let mouseUpListener = (e: MouseEvent) => { 570 + savedSelection.current = null; 571 + if ( 572 + initialContentEditableParent.current && 573 + !(e.target as Element).getAttribute("data-draggable") && 574 + getContentEditableParent(e.target as Node) !== 575 + initialContentEditableParent.current 576 + ) { 577 + setTimeout(() => { 578 + window.getSelection()?.removeAllRanges(); 579 + }, 5); 580 + } 581 + initialContentEditableParent.current = null; 582 + useSelectingMouse.setState({ start: null }); 583 + setMouseDown(false); 584 + }; 585 + window.addEventListener("mousedown", mouseDownListener); 586 + window.addEventListener("mouseup", mouseUpListener); 587 + return () => { 588 + window.removeEventListener("mousedown", mouseDownListener); 589 + window.removeEventListener("mouseup", mouseUpListener); 590 + }; 591 + }, [entity_set.permissions.write, isMobile]); 592 + useEffect(() => { 593 + if (!mouseDown) return; 594 + if (isMobile) return; 595 + let mouseMoveListener = (e: MouseEvent) => { 596 + if (e.buttons !== 1) return; 597 + if (initialContentEditableParent.current) { 598 + if ( 599 + initialContentEditableParent.current === 600 + getContentEditableParent(e.target as Node) 601 + ) { 602 + if (savedSelection.current) { 603 + restoreSelection(savedSelection.current); 604 + } 605 + savedSelection.current = null; 606 + return; 607 + } 608 + if (!savedSelection.current) savedSelection.current = saveSelection(); 609 + window.getSelection()?.removeAllRanges(); 610 + } 611 + }; 612 + window.addEventListener("mousemove", mouseMoveListener); 613 + return () => { 614 + window.removeEventListener("mousemove", mouseMoveListener); 615 + }; 616 + }, [mouseDown, isMobile]); 617 + return null; 618 + } 619 + 620 + type SavedRange = { 621 + startContainer: Node; 622 + startOffset: number; 623 + endContainer: Node; 624 + endOffset: number; 625 + direction: "forward" | "backward"; 626 + }; 627 + function saveSelection() { 628 + let selection = window.getSelection(); 629 + if (selection && selection.rangeCount > 0) { 630 + let ranges: SavedRange[] = []; 631 + for (let i = 0; i < selection.rangeCount; i++) { 632 + let range = selection.getRangeAt(i); 633 + ranges.push({ 634 + startContainer: range.startContainer, 635 + startOffset: range.startOffset, 636 + endContainer: range.endContainer, 637 + endOffset: range.endOffset, 638 + direction: 639 + selection.anchorNode === range.startContainer && 640 + selection.anchorOffset === range.startOffset 641 + ? "forward" 642 + : "backward", 643 + }); 644 + } 645 + return ranges; 646 + } 647 + return []; 648 + } 649 + 650 + function restoreSelection(savedRanges: SavedRange[]) { 651 + if (savedRanges && savedRanges.length > 0) { 652 + let selection = window.getSelection(); 653 + if (!selection) return; 654 + selection.removeAllRanges(); 655 + for (let i = 0; i < savedRanges.length; i++) { 656 + let range = document.createRange(); 657 + range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 658 + range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 659 + 660 + selection.addRange(range); 661 + 662 + // If the direction is backward, collapse the selection to the end and then extend it backward 663 + if (savedRanges[i].direction === "backward") { 664 + selection.collapseToEnd(); 665 + selection.extend( 666 + savedRanges[i].startContainer, 667 + savedRanges[i].startOffset, 668 + ); 669 + } 670 + } 671 + } 672 + } 673 + 674 + function getContentEditableParent(e: Node | null): Node | null { 675 + let element: Node | null = e; 676 + while (element && element !== document) { 677 + if ( 678 + (element as HTMLElement).contentEditable === "true" || 679 + (element as HTMLElement).getAttribute("data-editable-block") 680 + ) { 681 + return element; 682 + } 683 + element = element.parentNode; 684 + } 685 + return null; 686 + } 687 + 688 + 689 + function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 690 + let everyBlockHasMark = blocks.reduce((acc, block) => { 691 + let editor = useEditorStates.getState().editorStates[block]; 692 + if (!editor) return acc; 693 + let { view } = editor; 694 + let from = 0; 695 + let to = view.state.doc.content.size; 696 + let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 697 + return acc && hasMarkInRange; 698 + }, true); 699 + for (let block of blocks) { 700 + let editor = useEditorStates.getState().editorStates[block]; 701 + if (!editor) return; 702 + let { view } = editor; 703 + let tr = view.state.tr; 704 + 705 + let from = 0; 706 + let to = view.state.doc.content.size; 707 + 708 + tr.setMeta("bulkOp", true); 709 + if (everyBlockHasMark) { 710 + tr.removeMark(from, to, mark); 711 + } else { 712 + tr.addMark(from, to, mark.create(attrs)); 713 + } 714 + 715 + view.dispatch(tr); 716 + } 717 + }
+48
components/SelectionManager/selectionState.ts
···
··· 1 + import { create } from "zustand"; 2 + import { Replicache } from "replicache"; 3 + import { ReplicacheMutators } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + 7 + export const useSelectingMouse = create(() => ({ 8 + start: null as null | string, 9 + })); 10 + 11 + export const getSortedSelection = async ( 12 + rep: Replicache<ReplicacheMutators>, 13 + ) => { 14 + let selectedBlocks = useUIState.getState().selectedBlocks; 15 + let foldedBlocks = useUIState.getState().foldedBlocks; 16 + if (!selectedBlocks[0]) return [[], []]; 17 + let siblings = 18 + (await rep?.query((tx) => 19 + getBlocksWithType(tx, selectedBlocks[0].parent), 20 + )) || []; 21 + let sortedBlocks = siblings.filter((s) => { 22 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 23 + return selected; 24 + }); 25 + let sortedBlocksWithChildren = siblings.filter((s) => { 26 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 27 + if (s.listData && !selected) { 28 + //Select the children of folded list blocks (in order to copy them) 29 + return s.listData.path.find( 30 + (p) => 31 + selectedBlocks.find((sb) => sb.value === p.entity) && 32 + foldedBlocks.includes(p.entity), 33 + ); 34 + } 35 + return selected; 36 + }); 37 + return [ 38 + sortedBlocks, 39 + siblings.filter( 40 + (f) => 41 + !f.listData || 42 + !f.listData.path.find( 43 + (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 44 + ), 45 + ), 46 + sortedBlocksWithChildren, 47 + ]; 48 + };
-763
components/SelectionManager.tsx
··· 1 - "use client"; 2 - import { useEffect, useRef, useState } from "react"; 3 - import { create } from "zustand"; 4 - import { ReplicacheMutators, useReplicache } from "src/replicache"; 5 - import { useUIState } from "src/useUIState"; 6 - import { scanIndex } from "src/replicache/utils"; 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { useEditorStates } from "src/state/useEditorState"; 9 - import { useEntitySetContext } from "./EntitySetProvider"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { v7 } from "uuid"; 12 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 13 - import { addShortcut, Shortcut } from "src/shortcuts"; 14 - import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 15 - import { elementId } from "src/utils/elementId"; 16 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 17 - import { copySelection } from "src/utils/copySelection"; 18 - import { isTextBlock } from "src/utils/isTextBlock"; 19 - import { useIsMobile } from "src/hooks/isMobile"; 20 - import { deleteBlock } from "./Blocks/DeleteBlock"; 21 - import { Replicache } from "replicache"; 22 - import { schema } from "./Blocks/TextBlock/schema"; 23 - import { TextSelection } from "prosemirror-state"; 24 - import { MarkType } from "prosemirror-model"; 25 - export const useSelectingMouse = create(() => ({ 26 - start: null as null | string, 27 - })); 28 - 29 - //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 30 - // How does this relate to *when dragging* ? 31 - 32 - export function SelectionManager() { 33 - let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 34 - let entity_set = useEntitySetContext(); 35 - let { rep, undoManager } = useReplicache(); 36 - let isMobile = useIsMobile(); 37 - useEffect(() => { 38 - if (!entity_set.permissions.write || !rep) return; 39 - const getSortedSelectionBound = getSortedSelection.bind(null, rep); 40 - let shortcuts: Shortcut[] = [ 41 - { 42 - metaKey: true, 43 - key: "ArrowUp", 44 - handler: async () => { 45 - let [firstBlock] = 46 - (await rep?.query((tx) => 47 - getBlocksWithType( 48 - tx, 49 - useUIState.getState().selectedBlocks[0].parent, 50 - ), 51 - )) || []; 52 - if (firstBlock) focusBlock(firstBlock, { type: "start" }); 53 - }, 54 - }, 55 - { 56 - metaKey: true, 57 - key: "ArrowDown", 58 - handler: async () => { 59 - let blocks = 60 - (await rep?.query((tx) => 61 - getBlocksWithType( 62 - tx, 63 - useUIState.getState().selectedBlocks[0].parent, 64 - ), 65 - )) || []; 66 - let folded = useUIState.getState().foldedBlocks; 67 - blocks = blocks.filter( 68 - (f) => 69 - !f.listData || 70 - !f.listData.path.find( 71 - (path) => 72 - folded.includes(path.entity) && f.value !== path.entity, 73 - ), 74 - ); 75 - let lastBlock = blocks[blocks.length - 1]; 76 - if (lastBlock) focusBlock(lastBlock, { type: "end" }); 77 - }, 78 - }, 79 - { 80 - metaKey: true, 81 - altKey: true, 82 - key: ["l", "ยฌ"], 83 - handler: async () => { 84 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 85 - for (let block of sortedBlocks) { 86 - if (!block.listData) { 87 - await rep?.mutate.assertFact({ 88 - entity: block.value, 89 - attribute: "block/is-list", 90 - data: { type: "boolean", value: true }, 91 - }); 92 - } else { 93 - outdentFull(block, rep); 94 - } 95 - } 96 - }, 97 - }, 98 - { 99 - metaKey: true, 100 - shift: true, 101 - key: ["ArrowDown", "J"], 102 - handler: async () => { 103 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 104 - let block = sortedBlocks[0]; 105 - let nextBlock = siblings 106 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 107 - .find( 108 - (f) => 109 - f.listData && 110 - block.listData && 111 - !f.listData.path.find((f) => f.entity === block.value), 112 - ); 113 - if ( 114 - nextBlock?.listData && 115 - block.listData && 116 - nextBlock.listData.depth === block.listData.depth - 1 117 - ) { 118 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 119 - useUIState.getState().toggleFold(nextBlock.value); 120 - await rep?.mutate.moveBlock({ 121 - block: block.value, 122 - oldParent: block.listData?.parent, 123 - newParent: nextBlock.value, 124 - position: { type: "first" }, 125 - }); 126 - } else { 127 - await rep?.mutate.moveBlockDown({ 128 - entityID: block.value, 129 - parent: block.listData?.parent || block.parent, 130 - }); 131 - } 132 - }, 133 - }, 134 - { 135 - metaKey: true, 136 - shift: true, 137 - key: ["ArrowUp", "K"], 138 - handler: async () => { 139 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 140 - let block = sortedBlocks[0]; 141 - let previousBlock = 142 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 143 - if (previousBlock.value === block.listData?.parent) { 144 - previousBlock = 145 - siblings?.[ 146 - siblings.findIndex((s) => s.value === block.value) - 2 147 - ]; 148 - } 149 - 150 - if ( 151 - previousBlock?.listData && 152 - block.listData && 153 - block.listData.depth > 1 && 154 - !previousBlock.listData.path.find( 155 - (f) => f.entity === block.listData?.parent, 156 - ) 157 - ) { 158 - let depth = block.listData.depth; 159 - let newParent = previousBlock.listData.path.find( 160 - (f) => f.depth === depth - 1, 161 - ); 162 - if (!newParent) return; 163 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 164 - useUIState.getState().toggleFold(newParent.entity); 165 - rep?.mutate.moveBlock({ 166 - block: block.value, 167 - oldParent: block.listData?.parent, 168 - newParent: newParent.entity, 169 - position: { type: "end" }, 170 - }); 171 - } else { 172 - rep?.mutate.moveBlockUp({ 173 - entityID: block.value, 174 - parent: block.listData?.parent || block.parent, 175 - }); 176 - } 177 - }, 178 - }, 179 - 180 - { 181 - metaKey: true, 182 - shift: true, 183 - key: "Enter", 184 - handler: async () => { 185 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 186 - if (!sortedBlocks[0].listData) return; 187 - useUIState.getState().toggleFold(sortedBlocks[0].value); 188 - }, 189 - }, 190 - ]; 191 - if (moreThanOneSelected) 192 - shortcuts = shortcuts.concat([ 193 - { 194 - metaKey: true, 195 - key: "u", 196 - handler: async () => { 197 - let [sortedBlocks] = await getSortedSelectionBound(); 198 - toggleMarkInBlocks( 199 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 200 - schema.marks.underline, 201 - ); 202 - }, 203 - }, 204 - { 205 - metaKey: true, 206 - key: "i", 207 - handler: async () => { 208 - let [sortedBlocks] = await getSortedSelectionBound(); 209 - toggleMarkInBlocks( 210 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 211 - schema.marks.em, 212 - ); 213 - }, 214 - }, 215 - { 216 - metaKey: true, 217 - key: "b", 218 - handler: async () => { 219 - let [sortedBlocks] = await getSortedSelectionBound(); 220 - toggleMarkInBlocks( 221 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 222 - schema.marks.strong, 223 - ); 224 - }, 225 - }, 226 - { 227 - metaAndCtrl: true, 228 - key: "h", 229 - handler: async () => { 230 - let [sortedBlocks] = await getSortedSelectionBound(); 231 - toggleMarkInBlocks( 232 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 233 - schema.marks.highlight, 234 - { 235 - color: useUIState.getState().lastUsedHighlight, 236 - }, 237 - ); 238 - }, 239 - }, 240 - { 241 - metaAndCtrl: true, 242 - key: "x", 243 - handler: async () => { 244 - let [sortedBlocks] = await getSortedSelectionBound(); 245 - toggleMarkInBlocks( 246 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 247 - schema.marks.strikethrough, 248 - ); 249 - }, 250 - }, 251 - ]); 252 - let removeListener = addShortcut( 253 - shortcuts.map((shortcut) => ({ 254 - ...shortcut, 255 - handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 256 - })), 257 - ); 258 - let listener = async (e: KeyboardEvent) => 259 - undoManager.withUndoGroup(async () => { 260 - //used here and in cut 261 - const deleteBlocks = async () => { 262 - if (!entity_set.permissions.write) return; 263 - if (moreThanOneSelected) { 264 - e.preventDefault(); 265 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 266 - let selectedBlocks = useUIState.getState().selectedBlocks; 267 - let firstBlock = sortedBlocks[0]; 268 - 269 - await rep?.mutate.removeBlock( 270 - selectedBlocks.map((block) => ({ blockEntity: block.value })), 271 - ); 272 - useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 273 - 274 - let nextBlock = 275 - siblings?.[ 276 - siblings.findIndex((s) => s.value === firstBlock.value) - 1 277 - ]; 278 - if (nextBlock) { 279 - useUIState.getState().setSelectedBlock({ 280 - value: nextBlock.value, 281 - parent: nextBlock.parent, 282 - }); 283 - let type = await rep?.query((tx) => 284 - scanIndex(tx).eav(nextBlock.value, "block/type"), 285 - ); 286 - if (!type?.[0]) return; 287 - if ( 288 - type[0]?.data.value === "text" || 289 - type[0]?.data.value === "heading" 290 - ) 291 - focusBlock( 292 - { 293 - value: nextBlock.value, 294 - type: "text", 295 - parent: nextBlock.parent, 296 - }, 297 - { type: "end" }, 298 - ); 299 - } 300 - } 301 - }; 302 - if (e.key === "Backspace" || e.key === "Delete") { 303 - deleteBlocks(); 304 - } 305 - if (e.key === "ArrowUp") { 306 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 307 - let focusedBlock = useUIState.getState().focusedEntity; 308 - if (!e.shiftKey && !e.ctrlKey) { 309 - if (e.defaultPrevented) return; 310 - if (sortedBlocks.length === 1) return; 311 - let firstBlock = sortedBlocks[0]; 312 - if (!firstBlock) return; 313 - let type = await rep?.query((tx) => 314 - scanIndex(tx).eav(firstBlock.value, "block/type"), 315 - ); 316 - if (!type?.[0]) return; 317 - useUIState.getState().setSelectedBlock(firstBlock); 318 - focusBlock( 319 - { ...firstBlock, type: type[0].data.value }, 320 - { type: "start" }, 321 - ); 322 - } else { 323 - if (e.defaultPrevented) return; 324 - if ( 325 - sortedBlocks.length <= 1 || 326 - !focusedBlock || 327 - focusedBlock.entityType === "page" 328 - ) 329 - return; 330 - let b = focusedBlock; 331 - let focusedBlockIndex = sortedBlocks.findIndex( 332 - (s) => s.value == b.entityID, 333 - ); 334 - if (focusedBlockIndex === 0) { 335 - let index = siblings.findIndex((s) => s.value === b.entityID); 336 - let nextSelectedBlock = siblings[index - 1]; 337 - if (!nextSelectedBlock) return; 338 - 339 - scrollIntoViewIfNeeded( 340 - document.getElementById( 341 - elementId.block(nextSelectedBlock.value).container, 342 - ), 343 - false, 344 - ); 345 - useUIState.getState().addBlockToSelection({ 346 - ...nextSelectedBlock, 347 - }); 348 - useUIState.getState().setFocusedBlock({ 349 - entityType: "block", 350 - parent: nextSelectedBlock.parent, 351 - entityID: nextSelectedBlock.value, 352 - }); 353 - } else { 354 - let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 355 - useUIState.getState().setFocusedBlock({ 356 - entityType: "block", 357 - parent: b.parent, 358 - entityID: nextBlock.value, 359 - }); 360 - scrollIntoViewIfNeeded( 361 - document.getElementById( 362 - elementId.block(nextBlock.value).container, 363 - ), 364 - false, 365 - ); 366 - if (sortedBlocks.length === 2) { 367 - useEditorStates 368 - .getState() 369 - .editorStates[nextBlock.value]?.view?.focus(); 370 - } 371 - useUIState 372 - .getState() 373 - .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 374 - } 375 - } 376 - } 377 - if (e.key === "ArrowLeft") { 378 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 379 - if (sortedSelection.length === 1) return; 380 - let firstBlock = sortedSelection[0]; 381 - if (!firstBlock) return; 382 - let type = await rep?.query((tx) => 383 - scanIndex(tx).eav(firstBlock.value, "block/type"), 384 - ); 385 - if (!type?.[0]) return; 386 - useUIState.getState().setSelectedBlock(firstBlock); 387 - focusBlock( 388 - { ...firstBlock, type: type[0].data.value }, 389 - { type: "start" }, 390 - ); 391 - } 392 - if (e.key === "ArrowRight") { 393 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 394 - if (sortedSelection.length === 1) return; 395 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 396 - if (!lastBlock) return; 397 - let type = await rep?.query((tx) => 398 - scanIndex(tx).eav(lastBlock.value, "block/type"), 399 - ); 400 - if (!type?.[0]) return; 401 - useUIState.getState().setSelectedBlock(lastBlock); 402 - focusBlock( 403 - { ...lastBlock, type: type[0].data.value }, 404 - { type: "end" }, 405 - ); 406 - } 407 - if (e.key === "Tab") { 408 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 409 - if (sortedSelection.length <= 1) return; 410 - e.preventDefault(); 411 - if (e.shiftKey) { 412 - for (let i = siblings.length - 1; i >= 0; i--) { 413 - let block = siblings[i]; 414 - if (!sortedSelection.find((s) => s.value === block.value)) 415 - continue; 416 - if ( 417 - sortedSelection.find((s) => s.value === block.listData?.parent) 418 - ) 419 - continue; 420 - let parentoffset = 1; 421 - let previousBlock = siblings[i - parentoffset]; 422 - while ( 423 - previousBlock && 424 - sortedSelection.find((s) => previousBlock.value === s.value) 425 - ) { 426 - parentoffset += 1; 427 - previousBlock = siblings[i - parentoffset]; 428 - } 429 - if (!block.listData || !previousBlock.listData) continue; 430 - outdent(block, previousBlock, rep); 431 - } 432 - } else { 433 - for (let i = 0; i < siblings.length; i++) { 434 - let block = siblings[i]; 435 - if (!sortedSelection.find((s) => s.value === block.value)) 436 - continue; 437 - if ( 438 - sortedSelection.find((s) => s.value === block.listData?.parent) 439 - ) 440 - continue; 441 - let parentoffset = 1; 442 - let previousBlock = siblings[i - parentoffset]; 443 - while ( 444 - previousBlock && 445 - sortedSelection.find((s) => previousBlock.value === s.value) 446 - ) { 447 - parentoffset += 1; 448 - previousBlock = siblings[i - parentoffset]; 449 - } 450 - if (!block.listData || !previousBlock.listData) continue; 451 - indent(block, previousBlock, rep); 452 - } 453 - } 454 - } 455 - if (e.key === "ArrowDown") { 456 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 457 - let focusedBlock = useUIState.getState().focusedEntity; 458 - if (!e.shiftKey) { 459 - if (sortedSelection.length === 1) return; 460 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 461 - if (!lastBlock) return; 462 - let type = await rep?.query((tx) => 463 - scanIndex(tx).eav(lastBlock.value, "block/type"), 464 - ); 465 - if (!type?.[0]) return; 466 - useUIState.getState().setSelectedBlock(lastBlock); 467 - focusBlock( 468 - { ...lastBlock, type: type[0].data.value }, 469 - { type: "end" }, 470 - ); 471 - } 472 - if (e.shiftKey) { 473 - if (e.defaultPrevented) return; 474 - if ( 475 - sortedSelection.length <= 1 || 476 - !focusedBlock || 477 - focusedBlock.entityType === "page" 478 - ) 479 - return; 480 - let b = focusedBlock; 481 - let focusedBlockIndex = sortedSelection.findIndex( 482 - (s) => s.value == b.entityID, 483 - ); 484 - if (focusedBlockIndex === sortedSelection.length - 1) { 485 - let index = siblings.findIndex((s) => s.value === b.entityID); 486 - let nextSelectedBlock = siblings[index + 1]; 487 - if (!nextSelectedBlock) return; 488 - useUIState.getState().addBlockToSelection({ 489 - ...nextSelectedBlock, 490 - }); 491 - 492 - scrollIntoViewIfNeeded( 493 - document.getElementById( 494 - elementId.block(nextSelectedBlock.value).container, 495 - ), 496 - false, 497 - ); 498 - useUIState.getState().setFocusedBlock({ 499 - entityType: "block", 500 - parent: nextSelectedBlock.parent, 501 - entityID: nextSelectedBlock.value, 502 - }); 503 - } else { 504 - let nextBlock = sortedSelection[1]; 505 - useUIState 506 - .getState() 507 - .removeBlockFromSelection({ value: b.entityID }); 508 - scrollIntoViewIfNeeded( 509 - document.getElementById( 510 - elementId.block(nextBlock.value).container, 511 - ), 512 - false, 513 - ); 514 - useUIState.getState().setFocusedBlock({ 515 - entityType: "block", 516 - parent: b.parent, 517 - entityID: nextBlock.value, 518 - }); 519 - if (sortedSelection.length === 2) { 520 - useEditorStates 521 - .getState() 522 - .editorStates[nextBlock.value]?.view?.focus(); 523 - } 524 - } 525 - } 526 - } 527 - if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 528 - if (!rep) return; 529 - if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 530 - let [, , selectionWithFoldedChildren] = 531 - await getSortedSelectionBound(); 532 - if (!selectionWithFoldedChildren) return; 533 - let el = document.activeElement as HTMLElement; 534 - if ( 535 - el?.tagName === "LABEL" || 536 - el?.tagName === "INPUT" || 537 - el?.tagName === "TEXTAREA" 538 - ) { 539 - return; 540 - } 541 - 542 - if ( 543 - el.contentEditable === "true" && 544 - selectionWithFoldedChildren.length <= 1 545 - ) 546 - return; 547 - e.preventDefault(); 548 - await copySelection(rep, selectionWithFoldedChildren); 549 - if (e.key === "x") deleteBlocks(); 550 - } 551 - }); 552 - window.addEventListener("keydown", listener); 553 - return () => { 554 - removeListener(); 555 - window.removeEventListener("keydown", listener); 556 - }; 557 - }, [moreThanOneSelected, rep, entity_set.permissions.write]); 558 - 559 - let [mouseDown, setMouseDown] = useState(false); 560 - let initialContentEditableParent = useRef<null | Node>(null); 561 - let savedSelection = useRef<SavedRange[] | null>(undefined); 562 - useEffect(() => { 563 - if (isMobile) return; 564 - if (!entity_set.permissions.write) return; 565 - let mouseDownListener = (e: MouseEvent) => { 566 - if ((e.target as Element).getAttribute("data-draggable")) return; 567 - let contentEditableParent = getContentEditableParent(e.target as Node); 568 - if (contentEditableParent) { 569 - setMouseDown(true); 570 - let entityID = (contentEditableParent as Element).getAttribute( 571 - "data-entityid", 572 - ); 573 - useSelectingMouse.setState({ start: entityID }); 574 - } 575 - initialContentEditableParent.current = contentEditableParent; 576 - }; 577 - let mouseUpListener = (e: MouseEvent) => { 578 - savedSelection.current = null; 579 - if ( 580 - initialContentEditableParent.current && 581 - !(e.target as Element).getAttribute("data-draggable") && 582 - getContentEditableParent(e.target as Node) !== 583 - initialContentEditableParent.current 584 - ) { 585 - setTimeout(() => { 586 - window.getSelection()?.removeAllRanges(); 587 - }, 5); 588 - } 589 - initialContentEditableParent.current = null; 590 - useSelectingMouse.setState({ start: null }); 591 - setMouseDown(false); 592 - }; 593 - window.addEventListener("mousedown", mouseDownListener); 594 - window.addEventListener("mouseup", mouseUpListener); 595 - return () => { 596 - window.removeEventListener("mousedown", mouseDownListener); 597 - window.removeEventListener("mouseup", mouseUpListener); 598 - }; 599 - }, [entity_set.permissions.write, isMobile]); 600 - useEffect(() => { 601 - if (!mouseDown) return; 602 - if (isMobile) return; 603 - let mouseMoveListener = (e: MouseEvent) => { 604 - if (e.buttons !== 1) return; 605 - if (initialContentEditableParent.current) { 606 - if ( 607 - initialContentEditableParent.current === 608 - getContentEditableParent(e.target as Node) 609 - ) { 610 - if (savedSelection.current) { 611 - restoreSelection(savedSelection.current); 612 - } 613 - savedSelection.current = null; 614 - return; 615 - } 616 - if (!savedSelection.current) savedSelection.current = saveSelection(); 617 - window.getSelection()?.removeAllRanges(); 618 - } 619 - }; 620 - window.addEventListener("mousemove", mouseMoveListener); 621 - return () => { 622 - window.removeEventListener("mousemove", mouseMoveListener); 623 - }; 624 - }, [mouseDown, isMobile]); 625 - return null; 626 - } 627 - 628 - type SavedRange = { 629 - startContainer: Node; 630 - startOffset: number; 631 - endContainer: Node; 632 - endOffset: number; 633 - direction: "forward" | "backward"; 634 - }; 635 - export function saveSelection() { 636 - let selection = window.getSelection(); 637 - if (selection && selection.rangeCount > 0) { 638 - let ranges: SavedRange[] = []; 639 - for (let i = 0; i < selection.rangeCount; i++) { 640 - let range = selection.getRangeAt(i); 641 - ranges.push({ 642 - startContainer: range.startContainer, 643 - startOffset: range.startOffset, 644 - endContainer: range.endContainer, 645 - endOffset: range.endOffset, 646 - direction: 647 - selection.anchorNode === range.startContainer && 648 - selection.anchorOffset === range.startOffset 649 - ? "forward" 650 - : "backward", 651 - }); 652 - } 653 - return ranges; 654 - } 655 - return []; 656 - } 657 - 658 - export function restoreSelection(savedRanges: SavedRange[]) { 659 - if (savedRanges && savedRanges.length > 0) { 660 - let selection = window.getSelection(); 661 - if (!selection) return; 662 - selection.removeAllRanges(); 663 - for (let i = 0; i < savedRanges.length; i++) { 664 - let range = document.createRange(); 665 - range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 666 - range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 667 - 668 - selection.addRange(range); 669 - 670 - // If the direction is backward, collapse the selection to the end and then extend it backward 671 - if (savedRanges[i].direction === "backward") { 672 - selection.collapseToEnd(); 673 - selection.extend( 674 - savedRanges[i].startContainer, 675 - savedRanges[i].startOffset, 676 - ); 677 - } 678 - } 679 - } 680 - } 681 - 682 - function getContentEditableParent(e: Node | null): Node | null { 683 - let element: Node | null = e; 684 - while (element && element !== document) { 685 - if ( 686 - (element as HTMLElement).contentEditable === "true" || 687 - (element as HTMLElement).getAttribute("data-editable-block") 688 - ) { 689 - return element; 690 - } 691 - element = element.parentNode; 692 - } 693 - return null; 694 - } 695 - 696 - export const getSortedSelection = async ( 697 - rep: Replicache<ReplicacheMutators>, 698 - ) => { 699 - let selectedBlocks = useUIState.getState().selectedBlocks; 700 - let foldedBlocks = useUIState.getState().foldedBlocks; 701 - if (!selectedBlocks[0]) return [[], []]; 702 - let siblings = 703 - (await rep?.query((tx) => 704 - getBlocksWithType(tx, selectedBlocks[0].parent), 705 - )) || []; 706 - let sortedBlocks = siblings.filter((s) => { 707 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 708 - return selected; 709 - }); 710 - let sortedBlocksWithChildren = siblings.filter((s) => { 711 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 712 - if (s.listData && !selected) { 713 - //Select the children of folded list blocks (in order to copy them) 714 - return s.listData.path.find( 715 - (p) => 716 - selectedBlocks.find((sb) => sb.value === p.entity) && 717 - foldedBlocks.includes(p.entity), 718 - ); 719 - } 720 - return selected; 721 - }); 722 - return [ 723 - sortedBlocks, 724 - siblings.filter( 725 - (f) => 726 - !f.listData || 727 - !f.listData.path.find( 728 - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 729 - ), 730 - ), 731 - sortedBlocksWithChildren, 732 - ]; 733 - }; 734 - 735 - function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 736 - let everyBlockHasMark = blocks.reduce((acc, block) => { 737 - let editor = useEditorStates.getState().editorStates[block]; 738 - if (!editor) return acc; 739 - let { view } = editor; 740 - let from = 0; 741 - let to = view.state.doc.content.size; 742 - let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 743 - return acc && hasMarkInRange; 744 - }, true); 745 - for (let block of blocks) { 746 - let editor = useEditorStates.getState().editorStates[block]; 747 - if (!editor) return; 748 - let { view } = editor; 749 - let tr = view.state.tr; 750 - 751 - let from = 0; 752 - let to = view.state.doc.content.size; 753 - 754 - tr.setMeta("bulkOp", true); 755 - if (everyBlockHasMark) { 756 - tr.removeMark(from, to, mark); 757 - } else { 758 - tr.addMark(from, to, mark.create(attrs)); 759 - } 760 - 761 - view.dispatch(tr); 762 - } 763 - }
···
+296
components/Tags.tsx
···
··· 1 + "use client"; 2 + import { CloseTiny } from "components/Icons/CloseTiny"; 3 + import { Input } from "components/Input"; 4 + import { useState, useRef } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { Popover } from "components/Popover"; 7 + import Link from "next/link"; 8 + import { searchTags, type TagSearchResult } from "actions/searchTags"; 9 + 10 + export const Tag = (props: { 11 + name: string; 12 + selected?: boolean; 13 + onDelete?: (tag: string) => void; 14 + className?: string; 15 + }) => { 16 + return ( 17 + <div 18 + className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`} 19 + > 20 + <Link 21 + href={`https://leaflet.pub/tag/${encodeURIComponent(props.name)}`} 22 + className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`} 23 + > 24 + {props.name}{" "} 25 + </Link> 26 + {props.selected ? ( 27 + <button 28 + type="button" 29 + onClick={() => (props.onDelete ? props.onDelete(props.name) : null)} 30 + > 31 + <CloseTiny className="scale-75 pr-1 text-accent-2" /> 32 + </button> 33 + ) : null} 34 + </div> 35 + ); 36 + }; 37 + 38 + export const TagSelector = (props: { 39 + selectedTags: string[]; 40 + setSelectedTags: (tags: string[]) => void; 41 + }) => { 42 + return ( 43 + <div className="flex flex-col gap-2 text-primary"> 44 + <TagSearchInput 45 + selectedTags={props.selectedTags} 46 + setSelectedTags={props.setSelectedTags} 47 + /> 48 + {props.selectedTags.length > 0 ? ( 49 + <div className="flex flex-wrap gap-2 "> 50 + {props.selectedTags.map((tag) => ( 51 + <Tag 52 + key={tag} 53 + name={tag} 54 + selected 55 + onDelete={() => { 56 + props.setSelectedTags( 57 + props.selectedTags.filter((t) => t !== tag), 58 + ); 59 + }} 60 + /> 61 + ))} 62 + </div> 63 + ) : ( 64 + <div className="text-tertiary italic text-sm h-6">no tags selected</div> 65 + )} 66 + </div> 67 + ); 68 + }; 69 + 70 + export const TagSearchInput = (props: { 71 + selectedTags: string[]; 72 + setSelectedTags: (tags: string[]) => void; 73 + }) => { 74 + let [tagInputValue, setTagInputValue] = useState(""); 75 + let [isOpen, setIsOpen] = useState(false); 76 + let [highlightedIndex, setHighlightedIndex] = useState(0); 77 + let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]); 78 + let [isSearching, setIsSearching] = useState(false); 79 + 80 + const placeholderInputRef = useRef<HTMLButtonElement | null>(null); 81 + 82 + let inputWidth = placeholderInputRef.current?.clientWidth; 83 + 84 + // Fetch tags whenever the input value changes 85 + useDebouncedEffect( 86 + async () => { 87 + setIsSearching(true); 88 + const results = await searchTags(tagInputValue); 89 + if (results) { 90 + setSearchResults(results); 91 + } 92 + setIsSearching(false); 93 + }, 94 + 300, 95 + [tagInputValue], 96 + ); 97 + 98 + const filteredTags = searchResults 99 + .filter((tag) => !props.selectedTags.includes(tag.name)) 100 + .filter((tag) => 101 + tag.name.toLowerCase().includes(tagInputValue.toLowerCase()), 102 + ); 103 + 104 + const showResults = tagInputValue.length >= 3; 105 + 106 + function clearTagInput() { 107 + setHighlightedIndex(0); 108 + setTagInputValue(""); 109 + } 110 + 111 + function selectTag(tag: string) { 112 + console.log("selected " + tag); 113 + props.setSelectedTags([...props.selectedTags, tag]); 114 + clearTagInput(); 115 + } 116 + 117 + const handleKeyDown = ( 118 + e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>, 119 + ) => { 120 + if (!isOpen) return; 121 + 122 + if (e.key === "ArrowDown") { 123 + e.preventDefault(); 124 + setHighlightedIndex((prev) => 125 + prev < filteredTags.length ? prev + 1 : prev, 126 + ); 127 + } else if (e.key === "ArrowUp") { 128 + e.preventDefault(); 129 + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); 130 + } else if (e.key === "Enter") { 131 + e.preventDefault(); 132 + selectTag( 133 + userInputResult 134 + ? highlightedIndex === 0 135 + ? tagInputValue 136 + : filteredTags[highlightedIndex - 1].name 137 + : filteredTags[highlightedIndex].name, 138 + ); 139 + clearTagInput(); 140 + } else if (e.key === "Escape") { 141 + setIsOpen(false); 142 + } 143 + }; 144 + 145 + const userInputResult = 146 + showResults && 147 + tagInputValue !== "" && 148 + !filteredTags.some((tag) => tag.name === tagInputValue); 149 + 150 + return ( 151 + <div className="relative"> 152 + <Input 153 + className="input-with-border grow w-full outline-none!" 154 + id="placeholder-tag-search-input" 155 + value={tagInputValue} 156 + placeholder="search tagsโ€ฆ" 157 + onChange={(e) => { 158 + setTagInputValue(e.target.value); 159 + setIsOpen(true); 160 + setHighlightedIndex(0); 161 + }} 162 + onKeyDown={handleKeyDown} 163 + onFocus={() => { 164 + setIsOpen(true); 165 + document.getElementById("tag-search-input")?.focus(); 166 + }} 167 + /> 168 + <Popover 169 + open={isOpen} 170 + onOpenChange={() => { 171 + setIsOpen(!isOpen); 172 + if (!isOpen) 173 + setTimeout(() => { 174 + document.getElementById("tag-search-input")?.focus(); 175 + }, 100); 176 + }} 177 + className="w-full p-2! min-w-xs text-primary" 178 + sideOffset={-39} 179 + onOpenAutoFocus={(e) => e.preventDefault()} 180 + asChild 181 + trigger={ 182 + <button 183 + ref={placeholderInputRef} 184 + className="absolute left-0 top-0 right-0 h-[30px]" 185 + ></button> 186 + } 187 + noArrow 188 + > 189 + <div className="" style={{ width: `${inputWidth}px` }}> 190 + <Input 191 + className="input-with-border grow w-full mb-2" 192 + id="tag-search-input" 193 + placeholder="search tagsโ€ฆ" 194 + value={tagInputValue} 195 + onChange={(e) => { 196 + setTagInputValue(e.target.value); 197 + setIsOpen(true); 198 + setHighlightedIndex(0); 199 + }} 200 + onKeyDown={handleKeyDown} 201 + onFocus={() => { 202 + setIsOpen(true); 203 + }} 204 + /> 205 + {props.selectedTags.length > 0 ? ( 206 + <div className="flex flex-wrap gap-2 pb-[6px]"> 207 + {props.selectedTags.map((tag) => ( 208 + <Tag 209 + key={tag} 210 + name={tag} 211 + selected 212 + onDelete={() => { 213 + props.setSelectedTags( 214 + props.selectedTags.filter((t) => t !== tag), 215 + ); 216 + }} 217 + /> 218 + ))} 219 + </div> 220 + ) : ( 221 + <div className="text-tertiary italic text-sm h-6"> 222 + no tags selected 223 + </div> 224 + )} 225 + <hr className=" mb-[2px] border-border-light" /> 226 + 227 + {showResults ? ( 228 + <> 229 + {userInputResult && ( 230 + <TagResult 231 + key={"userInput"} 232 + index={0} 233 + name={tagInputValue} 234 + tagged={0} 235 + highlighted={0 === highlightedIndex} 236 + setHighlightedIndex={setHighlightedIndex} 237 + onSelect={() => { 238 + selectTag(tagInputValue); 239 + }} 240 + /> 241 + )} 242 + {filteredTags.map((tag, i) => ( 243 + <TagResult 244 + key={tag.name} 245 + index={userInputResult ? i + 1 : i} 246 + name={tag.name} 247 + tagged={tag.document_count} 248 + highlighted={ 249 + (userInputResult ? i + 1 : i) === highlightedIndex 250 + } 251 + setHighlightedIndex={setHighlightedIndex} 252 + onSelect={() => { 253 + selectTag(tag.name); 254 + }} 255 + /> 256 + ))} 257 + </> 258 + ) : ( 259 + <div className="text-tertiary italic text-sm py-1"> 260 + type at least 3 characters to search 261 + </div> 262 + )} 263 + </div> 264 + </Popover> 265 + </div> 266 + ); 267 + }; 268 + 269 + const TagResult = (props: { 270 + name: string; 271 + tagged: number; 272 + onSelect: () => void; 273 + index: number; 274 + highlighted: boolean; 275 + setHighlightedIndex: (i: number) => void; 276 + }) => { 277 + return ( 278 + <div className="-mx-1"> 279 + <button 280 + className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`} 281 + onSelect={(e) => { 282 + e.preventDefault(); 283 + props.onSelect(); 284 + }} 285 + onClick={(e) => { 286 + e.preventDefault(); 287 + props.onSelect(); 288 + }} 289 + onMouseEnter={(e) => props.setHighlightedIndex(props.index)} 290 + > 291 + {props.name} 292 + <div className="text-tertiary text-sm"> {props.tagged}</div> 293 + </button> 294 + </div> 295 + ); 296 + };
+4 -2
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 84 <div 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 88 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 }}
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 + import { getColorContrast } from "./themeUtils"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 84 <div 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 style={{ 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 92 }}
+1 -40
components/ThemeManager/ThemeProvider.tsx
··· 5 CSSProperties, 6 useContext, 7 useEffect, 8 - useMemo, 9 - useState, 10 } from "react"; 11 import { 12 colorToString, ··· 14 useColorAttributeNullable, 15 } from "./useColorAttribute"; 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 19 import { useEntity } from "src/replicache"; 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 PublicationThemeProvider, 24 } from "./PublicationThemeProvider"; 25 import { PubLeafletPublication } from "lexicons/api"; 26 - 27 - type CSSVariables = { 28 - "--bg-leaflet": string; 29 - "--bg-page": string; 30 - "--primary": string; 31 - "--accent-1": string; 32 - "--accent-2": string; 33 - "--accent-contrast": string; 34 - "--highlight-1": string; 35 - "--highlight-2": string; 36 - "--highlight-3": string; 37 - }; 38 - 39 - // define the color defaults for everything 40 - export const ThemeDefaults = { 41 - "theme/page-background": "#FDFCFA", 42 - "theme/card-background": "#FFFFFF", 43 - "theme/primary": "#272727", 44 - "theme/highlight-1": "#FFFFFF", 45 - "theme/highlight-2": "#EDD280", 46 - "theme/highlight-3": "#FFCDC3", 47 - 48 - //everywhere else, accent-background = accent-1 and accent-text = accent-2. 49 - // we just need to create a migration pipeline before we can change this 50 - "theme/accent-text": "#FFFFFF", 51 - "theme/accent-background": "#0000FF", 52 - "theme/accent-contrast": "#0000FF", 53 - }; 54 55 // define a function to set an Aria Color to a CSS Variable in RGB 56 function setCSSVariableToColor( ··· 368 ); 369 }; 370 371 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 372 - export function getColorContrast(color1: string, color2: string) { 373 - ColorSpace.register(sRGB); 374 - 375 - let parsedColor1 = parse(`rgb(${color1})`); 376 - let parsedColor2 = parse(`rgb(${color2})`); 377 - 378 - return contrastLstar(parsedColor1, parsedColor2); 379 - }
··· 5 CSSProperties, 6 useContext, 7 useEffect, 8 } from "react"; 9 import { 10 colorToString, ··· 12 useColorAttributeNullable, 13 } from "./useColorAttribute"; 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 15 16 import { useEntity } from "src/replicache"; 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 20 PublicationThemeProvider, 21 } from "./PublicationThemeProvider"; 22 import { PubLeafletPublication } from "lexicons/api"; 23 + import { getColorContrast } from "./themeUtils"; 24 25 // define a function to set an Aria Color to a CSS Variable in RGB 26 function setCSSVariableToColor( ··· 338 ); 339 }; 340
+27
components/ThemeManager/themeUtils.ts
···
··· 1 + import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 + 3 + // define the color defaults for everything 4 + export const ThemeDefaults = { 5 + "theme/page-background": "#FDFCFA", 6 + "theme/card-background": "#FFFFFF", 7 + "theme/primary": "#272727", 8 + "theme/highlight-1": "#FFFFFF", 9 + "theme/highlight-2": "#EDD280", 10 + "theme/highlight-3": "#FFCDC3", 11 + 12 + //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 + // we just need to create a migration pipeline before we can change this 14 + "theme/accent-text": "#FFFFFF", 15 + "theme/accent-background": "#0000FF", 16 + "theme/accent-contrast": "#0000FF", 17 + }; 18 + 19 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorContrast(color1: string, color2: string) { 21 + ColorSpace.register(sRGB); 22 + 23 + let parsedColor1 = parse(`rgb(${color1})`); 24 + let parsedColor2 = parse(`rgb(${color2})`); 25 + 26 + return contrastLstar(parsedColor1, parsedColor2); 27 + }
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 6 7 export function useColorAttribute( 8 entity: string | null,
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 7 export function useColorAttribute( 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { useUIState } from "src/useUIState"; 7 import { LockBlockButton } from "./LockBlockButton"; 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 - const getSortedSelection = async () => { 70 - let selectedBlocks = useUIState.getState().selectedBlocks; 71 - let siblings = 72 - (await rep?.query((tx) => 73 - getBlocksWithType(tx, selectedBlocks[0].parent), 74 - )) || []; 75 - let sortedBlocks = siblings.filter((s) => 76 - selectedBlocks.find((sb) => sb.value === s.value), 77 - ); 78 - return [sortedBlocks, siblings]; 79 - }; 80 return ( 81 <> 82 <ToolbarButton 83 hiddenOnCanvas 84 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 86 if (sortedBlocks.length > 1) return; 87 let block = sortedBlocks[0]; 88 let previousBlock = ··· 139 <ToolbarButton 140 hiddenOnCanvas 141 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 143 if (sortedBlocks.length > 1) return; 144 let block = sortedBlocks[0]; 145 let nextBlock = siblings
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 return ( 70 <> 71 <ToolbarButton 72 hiddenOnCanvas 73 onClick={async () => { 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 76 if (sortedBlocks.length > 1) return; 77 let block = sortedBlocks[0]; 78 let previousBlock = ··· 129 <ToolbarButton 130 hiddenOnCanvas 131 onClick={async () => { 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 134 if (sortedBlocks.length > 1) return; 135 let block = sortedBlocks[0]; 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 17 import { TooltipButton } from "components/Buttons"; 18 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 import { useIsMobile } from "src/hooks/isMobile";
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 18 import { TooltipButton } from "components/Buttons"; 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
+31
lexicons/api/lexicons.ts
··· 1440 type: 'ref', 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 }, 1443 pages: { 1444 type: 'array', 1445 items: { ··· 1865 type: 'union', 1866 refs: [ 1867 'lex:pub.leaflet.richtext.facet#link', 1868 'lex:pub.leaflet.richtext.facet#code', 1869 'lex:pub.leaflet.richtext.facet#highlight', 1870 'lex:pub.leaflet.richtext.facet#underline', ··· 1901 properties: { 1902 uri: { 1903 type: 'string', 1904 }, 1905 }, 1906 },
··· 1440 type: 'ref', 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 + }, 1450 pages: { 1451 type: 'array', 1452 items: { ··· 1872 type: 'union', 1873 refs: [ 1874 'lex:pub.leaflet.richtext.facet#link', 1875 + 'lex:pub.leaflet.richtext.facet#didMention', 1876 + 'lex:pub.leaflet.richtext.facet#atMention', 1877 'lex:pub.leaflet.richtext.facet#code', 1878 'lex:pub.leaflet.richtext.facet#highlight', 1879 'lex:pub.leaflet.richtext.facet#underline', ··· 1910 properties: { 1911 uri: { 1912 type: 'string', 1913 + }, 1914 + }, 1915 + }, 1916 + didMention: { 1917 + type: 'object', 1918 + description: 'Facet feature for mentioning a did.', 1919 + required: ['did'], 1920 + properties: { 1921 + did: { 1922 + type: 'string', 1923 + format: 'did', 1924 + }, 1925 + }, 1926 + }, 1927 + atMention: { 1928 + type: 'object', 1929 + description: 'Facet feature for mentioning an AT URI.', 1930 + required: ['atURI'], 1931 + properties: { 1932 + atURI: { 1933 + type: 'string', 1934 + format: 'uri', 1935 }, 1936 }, 1937 },
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 publication?: string 24 author: string 25 theme?: PubLeafletPublication.Theme 26 pages: ( 27 | $Typed<PubLeafletPagesLinearDocument.Main> 28 | $Typed<PubLeafletPagesCanvas.Main>
··· 23 publication?: string 24 author: string 25 theme?: PubLeafletPublication.Theme 26 + tags?: string[] 27 pages: ( 28 | $Typed<PubLeafletPagesLinearDocument.Main> 29 | $Typed<PubLeafletPagesCanvas.Main>
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 | $Typed<Code> 24 | $Typed<Highlight> 25 | $Typed<Underline> ··· 72 73 export function validateLink<V>(v: V) { 74 return validate<Link & V>(v, id, hashLink) 75 } 76 77 /** Facet feature for inline code. */
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 25 | $Typed<Code> 26 | $Typed<Highlight> 27 | $Typed<Underline> ··· 74 75 export function validateLink<V>(v: V) { 76 return validate<Link & V>(v, id, hashLink) 77 + } 78 + 79 + /** Facet feature for mentioning a did. */ 80 + export interface DidMention { 81 + $type?: 'pub.leaflet.richtext.facet#didMention' 82 + did: string 83 + } 84 + 85 + const hashDidMention = 'didMention' 86 + 87 + export function isDidMention<V>(v: V) { 88 + return is$typed(v, id, hashDidMention) 89 + } 90 + 91 + export function validateDidMention<V>(v: V) { 92 + return validate<DidMention & V>(v, id, hashDidMention) 93 + } 94 + 95 + /** Facet feature for mentioning an AT URI. */ 96 + export interface AtMention { 97 + $type?: 'pub.leaflet.richtext.facet#atMention' 98 + atURI: string 99 + } 100 + 101 + const hashAtMention = 'atMention' 102 + 103 + export function isAtMention<V>(v: V) { 104 + return is$typed(v, id, hashAtMention) 105 + } 106 + 107 + export function validateAtMention<V>(v: V) { 108 + return validate<AtMention & V>(v, id, hashAtMention) 109 } 110 111 /** Facet feature for inline code. */
+7
lexicons/pub/leaflet/document.json
··· 46 "type": "ref", 47 "ref": "pub.leaflet.publication#theme" 48 }, 49 "pages": { 50 "type": "array", 51 "items": {
··· 46 "type": "ref", 47 "ref": "pub.leaflet.publication#theme" 48 }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 + }, 56 "pages": { 57 "type": "array", 58 "items": {
+28
lexicons/pub/leaflet/richtext/facet.json
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 "#code", 24 "#highlight", 25 "#underline", ··· 59 "properties": { 60 "uri": { 61 "type": "string" 62 } 63 } 64 },
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 + "#didMention", 24 + "#atMention", 25 "#code", 26 "#highlight", 27 "#underline", ··· 61 "properties": { 62 "uri": { 63 "type": "string" 64 + } 65 + } 66 + }, 67 + "didMention": { 68 + "type": "object", 69 + "description": "Facet feature for mentioning a did.", 70 + "required": [ 71 + "did" 72 + ], 73 + "properties": { 74 + "did": { 75 + "type": "string", 76 + "format": "did" 77 + } 78 + } 79 + }, 80 + "atMention": { 81 + "type": "object", 82 + "description": "Facet feature for mentioning an AT URI.", 83 + "required": [ 84 + "atURI" 85 + ], 86 + "properties": { 87 + "atURI": { 88 + "type": "string", 89 + "format": "uri" 90 } 91 } 92 },
+1
lexicons/src/document.ts
··· 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 pages: { 27 type: "array", 28 items: {
··· 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 pages: { 28 type: "array", 29 items: {
+12
lexicons/src/facet.ts
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 code: { 13 type: "object", 14 description: "Facet feature for inline code.",
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 + didMention: { 13 + type: "object", 14 + description: "Facet feature for mentioning a did.", 15 + required: ["did"], 16 + properties: { did: { type: "string", format: "did" } }, 17 + }, 18 + atMention: { 19 + type: "object", 20 + description: "Facet feature for mentioning an AT URI.", 21 + required: ["atURI"], 22 + properties: { atURI: { type: "string", format: "uri" } }, 23 + }, 24 code: { 25 type: "object", 26 description: "Facet feature for inline code.",
+68 -49
package-lock.json
··· 48 "inngest": "^3.40.1", 49 "ioredis": "^5.6.1", 50 "katex": "^0.16.22", 51 "linkifyjs": "^4.2.0", 52 "luxon": "^3.7.2", 53 "multiformats": "^13.3.2", 54 - "next": "16.0.3", 55 "pg": "^8.16.3", 56 "prosemirror-commands": "^1.5.2", 57 "prosemirror-inputrules": "^1.4.0", ··· 59 "prosemirror-model": "^1.21.0", 60 "prosemirror-schema-basic": "^1.2.2", 61 "prosemirror-state": "^1.4.3", 62 - "react": "19.2.0", 63 "react-aria-components": "^1.8.0", 64 "react-day-picker": "^9.3.0", 65 - "react-dom": "19.2.0", 66 "react-use-measure": "^2.1.1", 67 "redlock": "^5.0.0-beta.2", 68 "rehype-parse": "^9.0.0", ··· 2734 } 2735 }, 2736 "node_modules/@next/env": { 2737 - "version": "16.0.3", 2738 - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz", 2739 - "integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==" 2740 }, 2741 "node_modules/@next/eslint-plugin-next": { 2742 "version": "16.0.3", ··· 2804 } 2805 }, 2806 "node_modules/@next/swc-darwin-arm64": { 2807 - "version": "16.0.3", 2808 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz", 2809 - "integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==", 2810 "cpu": [ 2811 "arm64" 2812 ], 2813 "optional": true, 2814 "os": [ 2815 "darwin" ··· 2819 } 2820 }, 2821 "node_modules/@next/swc-darwin-x64": { 2822 - "version": "16.0.3", 2823 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz", 2824 - "integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==", 2825 "cpu": [ 2826 "x64" 2827 ], 2828 "optional": true, 2829 "os": [ 2830 "darwin" ··· 2834 } 2835 }, 2836 "node_modules/@next/swc-linux-arm64-gnu": { 2837 - "version": "16.0.3", 2838 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz", 2839 - "integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==", 2840 "cpu": [ 2841 "arm64" 2842 ], 2843 "optional": true, 2844 "os": [ 2845 "linux" ··· 2849 } 2850 }, 2851 "node_modules/@next/swc-linux-arm64-musl": { 2852 - "version": "16.0.3", 2853 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz", 2854 - "integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==", 2855 "cpu": [ 2856 "arm64" 2857 ], 2858 "optional": true, 2859 "os": [ 2860 "linux" ··· 2864 } 2865 }, 2866 "node_modules/@next/swc-linux-x64-gnu": { 2867 - "version": "16.0.3", 2868 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz", 2869 - "integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==", 2870 "cpu": [ 2871 "x64" 2872 ], 2873 "optional": true, 2874 "os": [ 2875 "linux" ··· 2879 } 2880 }, 2881 "node_modules/@next/swc-linux-x64-musl": { 2882 - "version": "16.0.3", 2883 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz", 2884 - "integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==", 2885 "cpu": [ 2886 "x64" 2887 ], 2888 "optional": true, 2889 "os": [ 2890 "linux" ··· 2894 } 2895 }, 2896 "node_modules/@next/swc-win32-arm64-msvc": { 2897 - "version": "16.0.3", 2898 - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz", 2899 - "integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==", 2900 "cpu": [ 2901 "arm64" 2902 ], 2903 "optional": true, 2904 "os": [ 2905 "win32" ··· 2909 } 2910 }, 2911 "node_modules/@next/swc-win32-x64-msvc": { 2912 - "version": "16.0.3", 2913 - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz", 2914 - "integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==", 2915 "cpu": [ 2916 "x64" 2917 ], 2918 "optional": true, 2919 "os": [ 2920 "win32" ··· 13360 "json-buffer": "3.0.1" 13361 } 13362 }, 13363 "node_modules/language-subtag-registry": { 13364 "version": "0.3.23", 13365 "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", ··· 15108 } 15109 }, 15110 "node_modules/next": { 15111 - "version": "16.0.3", 15112 - "resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz", 15113 - "integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==", 15114 "dependencies": { 15115 - "@next/env": "16.0.3", 15116 "@swc/helpers": "0.5.15", 15117 "caniuse-lite": "^1.0.30001579", 15118 "postcss": "8.4.31", ··· 15125 "node": ">=20.9.0" 15126 }, 15127 "optionalDependencies": { 15128 - "@next/swc-darwin-arm64": "16.0.3", 15129 - "@next/swc-darwin-x64": "16.0.3", 15130 - "@next/swc-linux-arm64-gnu": "16.0.3", 15131 - "@next/swc-linux-arm64-musl": "16.0.3", 15132 - "@next/swc-linux-x64-gnu": "16.0.3", 15133 - "@next/swc-linux-x64-musl": "16.0.3", 15134 - "@next/swc-win32-arm64-msvc": "16.0.3", 15135 - "@next/swc-win32-x64-msvc": "16.0.3", 15136 "sharp": "^0.34.4" 15137 }, 15138 "peerDependencies": { ··· 16321 } 16322 }, 16323 "node_modules/react": { 16324 - "version": "19.2.0", 16325 - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", 16326 - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", 16327 "engines": { 16328 "node": ">=0.10.0" 16329 } ··· 16442 } 16443 }, 16444 "node_modules/react-dom": { 16445 - "version": "19.2.0", 16446 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", 16447 - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", 16448 "dependencies": { 16449 "scheduler": "^0.27.0" 16450 }, 16451 "peerDependencies": { 16452 - "react": "^19.2.0" 16453 } 16454 }, 16455 "node_modules/react-is": {
··· 48 "inngest": "^3.40.1", 49 "ioredis": "^5.6.1", 50 "katex": "^0.16.22", 51 + "l": "^0.6.0", 52 "linkifyjs": "^4.2.0", 53 "luxon": "^3.7.2", 54 "multiformats": "^13.3.2", 55 + "next": "^16.0.7", 56 "pg": "^8.16.3", 57 "prosemirror-commands": "^1.5.2", 58 "prosemirror-inputrules": "^1.4.0", ··· 60 "prosemirror-model": "^1.21.0", 61 "prosemirror-schema-basic": "^1.2.2", 62 "prosemirror-state": "^1.4.3", 63 + "react": "19.2.1", 64 "react-aria-components": "^1.8.0", 65 "react-day-picker": "^9.3.0", 66 + "react-dom": "19.2.1", 67 "react-use-measure": "^2.1.1", 68 "redlock": "^5.0.0-beta.2", 69 "rehype-parse": "^9.0.0", ··· 2735 } 2736 }, 2737 "node_modules/@next/env": { 2738 + "version": "16.0.7", 2739 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", 2740 + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", 2741 + "license": "MIT" 2742 }, 2743 "node_modules/@next/eslint-plugin-next": { 2744 "version": "16.0.3", ··· 2806 } 2807 }, 2808 "node_modules/@next/swc-darwin-arm64": { 2809 + "version": "16.0.7", 2810 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", 2811 + "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", 2812 "cpu": [ 2813 "arm64" 2814 ], 2815 + "license": "MIT", 2816 "optional": true, 2817 "os": [ 2818 "darwin" ··· 2822 } 2823 }, 2824 "node_modules/@next/swc-darwin-x64": { 2825 + "version": "16.0.7", 2826 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", 2827 + "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", 2828 "cpu": [ 2829 "x64" 2830 ], 2831 + "license": "MIT", 2832 "optional": true, 2833 "os": [ 2834 "darwin" ··· 2838 } 2839 }, 2840 "node_modules/@next/swc-linux-arm64-gnu": { 2841 + "version": "16.0.7", 2842 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", 2843 + "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", 2844 "cpu": [ 2845 "arm64" 2846 ], 2847 + "license": "MIT", 2848 "optional": true, 2849 "os": [ 2850 "linux" ··· 2854 } 2855 }, 2856 "node_modules/@next/swc-linux-arm64-musl": { 2857 + "version": "16.0.7", 2858 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", 2859 + "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", 2860 "cpu": [ 2861 "arm64" 2862 ], 2863 + "license": "MIT", 2864 "optional": true, 2865 "os": [ 2866 "linux" ··· 2870 } 2871 }, 2872 "node_modules/@next/swc-linux-x64-gnu": { 2873 + "version": "16.0.7", 2874 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", 2875 + "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", 2876 "cpu": [ 2877 "x64" 2878 ], 2879 + "license": "MIT", 2880 "optional": true, 2881 "os": [ 2882 "linux" ··· 2886 } 2887 }, 2888 "node_modules/@next/swc-linux-x64-musl": { 2889 + "version": "16.0.7", 2890 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", 2891 + "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", 2892 "cpu": [ 2893 "x64" 2894 ], 2895 + "license": "MIT", 2896 "optional": true, 2897 "os": [ 2898 "linux" ··· 2902 } 2903 }, 2904 "node_modules/@next/swc-win32-arm64-msvc": { 2905 + "version": "16.0.7", 2906 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", 2907 + "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", 2908 "cpu": [ 2909 "arm64" 2910 ], 2911 + "license": "MIT", 2912 "optional": true, 2913 "os": [ 2914 "win32" ··· 2918 } 2919 }, 2920 "node_modules/@next/swc-win32-x64-msvc": { 2921 + "version": "16.0.7", 2922 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", 2923 + "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", 2924 "cpu": [ 2925 "x64" 2926 ], 2927 + "license": "MIT", 2928 "optional": true, 2929 "os": [ 2930 "win32" ··· 13370 "json-buffer": "3.0.1" 13371 } 13372 }, 13373 + "node_modules/l": { 13374 + "version": "0.6.0", 13375 + "resolved": "https://registry.npmjs.org/l/-/l-0.6.0.tgz", 13376 + "integrity": "sha512-rB5disIyfKRBQ1xcedByHCcAmPWy2NPnjWo5u4mVVIPtathROHyfHjkloqSBT49mLnSRnupkpoIUOFCL7irCVQ==", 13377 + "license": "MIT" 13378 + }, 13379 "node_modules/language-subtag-registry": { 13380 "version": "0.3.23", 13381 "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", ··· 15124 } 15125 }, 15126 "node_modules/next": { 15127 + "version": "16.0.7", 15128 + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", 15129 + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", 15130 + "license": "MIT", 15131 "dependencies": { 15132 + "@next/env": "16.0.7", 15133 "@swc/helpers": "0.5.15", 15134 "caniuse-lite": "^1.0.30001579", 15135 "postcss": "8.4.31", ··· 15142 "node": ">=20.9.0" 15143 }, 15144 "optionalDependencies": { 15145 + "@next/swc-darwin-arm64": "16.0.7", 15146 + "@next/swc-darwin-x64": "16.0.7", 15147 + "@next/swc-linux-arm64-gnu": "16.0.7", 15148 + "@next/swc-linux-arm64-musl": "16.0.7", 15149 + "@next/swc-linux-x64-gnu": "16.0.7", 15150 + "@next/swc-linux-x64-musl": "16.0.7", 15151 + "@next/swc-win32-arm64-msvc": "16.0.7", 15152 + "@next/swc-win32-x64-msvc": "16.0.7", 15153 "sharp": "^0.34.4" 15154 }, 15155 "peerDependencies": { ··· 16338 } 16339 }, 16340 "node_modules/react": { 16341 + "version": "19.2.1", 16342 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", 16343 + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", 16344 + "license": "MIT", 16345 "engines": { 16346 "node": ">=0.10.0" 16347 } ··· 16460 } 16461 }, 16462 "node_modules/react-dom": { 16463 + "version": "19.2.1", 16464 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", 16465 + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", 16466 + "license": "MIT", 16467 "dependencies": { 16468 "scheduler": "^0.27.0" 16469 }, 16470 "peerDependencies": { 16471 + "react": "^19.2.1" 16472 } 16473 }, 16474 "node_modules/react-is": {
+4 -3
package.json
··· 58 "inngest": "^3.40.1", 59 "ioredis": "^5.6.1", 60 "katex": "^0.16.22", 61 "linkifyjs": "^4.2.0", 62 "luxon": "^3.7.2", 63 "multiformats": "^13.3.2", 64 - "next": "16.0.3", 65 "pg": "^8.16.3", 66 "prosemirror-commands": "^1.5.2", 67 "prosemirror-inputrules": "^1.4.0", ··· 69 "prosemirror-model": "^1.21.0", 70 "prosemirror-schema-basic": "^1.2.2", 71 "prosemirror-state": "^1.4.3", 72 - "react": "19.2.0", 73 "react-aria-components": "^1.8.0", 74 "react-day-picker": "^9.3.0", 75 - "react-dom": "19.2.0", 76 "react-use-measure": "^2.1.1", 77 "redlock": "^5.0.0-beta.2", 78 "rehype-parse": "^9.0.0",
··· 58 "inngest": "^3.40.1", 59 "ioredis": "^5.6.1", 60 "katex": "^0.16.22", 61 + "l": "^0.6.0", 62 "linkifyjs": "^4.2.0", 63 "luxon": "^3.7.2", 64 "multiformats": "^13.3.2", 65 + "next": "^16.0.7", 66 "pg": "^8.16.3", 67 "prosemirror-commands": "^1.5.2", 68 "prosemirror-inputrules": "^1.4.0", ··· 70 "prosemirror-model": "^1.21.0", 71 "prosemirror-schema-basic": "^1.2.2", 72 "prosemirror-state": "^1.4.3", 73 + "react": "19.2.1", 74 "react-aria-components": "^1.8.0", 75 "react-day-picker": "^9.3.0", 76 + "react-dom": "19.2.1", 77 "react-use-measure": "^2.1.1", 78 "redlock": "^5.0.0-beta.2", 79 "rehype-parse": "^9.0.0",
+3 -1
src/hooks/useLocalizedDate.ts
··· 28 29 // On initial page load, use header timezone. After hydration, use system timezone 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 34 // Apply timezone if available 35 if (effectiveTimezone) {
··· 28 29 // On initial page load, use header timezone. After hydration, use system timezone 30 const effectiveTimezone = !hasPageLoaded 31 + ? timezone || "UTC" 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 35 36 // Apply timezone if available 37 if (effectiveTimezone) {
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 12 13 const listener = () => { 14 if (!ref.current?.scrollTop) return;
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 13 14 const listener = () => { 15 if (!ref.current?.scrollTop) return;
+254 -37
src/notifications.ts
··· 2 3 import { supabaseServerClient } from "supabase/serverClient"; 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 6 type NotificationRow = Tables<"notifications">; 7 ··· 12 export type NotificationData = 13 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 | { type: "subscribe"; subscription_uri: string } 15 - | { type: "quote"; bsky_post_uri: string; document_uri: string }; 16 17 export type HydratedNotification = 18 | HydratedCommentNotification 19 | HydratedSubscribeNotification 20 - | HydratedQuoteNotification; 21 export async function hydrateNotifications( 22 notifications: NotificationRow[], 23 ): Promise<Array<HydratedNotification>> { 24 // Call all hydrators in parallel 25 - const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ 26 hydrateCommentNotifications(notifications), 27 hydrateSubscribeNotifications(notifications), 28 hydrateQuoteNotifications(notifications), 29 ]); 30 31 // Combine all hydrated notifications 32 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; 33 34 // Sort by created_at to maintain order 35 allHydrated.sort( ··· 73 ) 74 .in("uri", commentUris); 75 76 - return commentNotifications.map((notification) => ({ 77 - id: notification.id, 78 - recipient: notification.recipient, 79 - created_at: notification.created_at, 80 - type: "comment" as const, 81 - comment_uri: notification.data.comment_uri, 82 - parentData: notification.data.parent_uri 83 - ? comments?.find((c) => c.uri === notification.data.parent_uri)! 84 - : undefined, 85 - commentData: comments?.find( 86 - (c) => c.uri === notification.data.comment_uri, 87 - )!, 88 - })); 89 } 90 91 export type HydratedSubscribeNotification = Awaited< ··· 113 .select("*, identities(bsky_profiles(*)), publications(*)") 114 .in("uri", subscriptionUris); 115 116 - return subscribeNotifications.map((notification) => ({ 117 - id: notification.id, 118 - recipient: notification.recipient, 119 - created_at: notification.created_at, 120 - type: "subscribe" as const, 121 - subscription_uri: notification.data.subscription_uri, 122 - subscriptionData: subscriptions?.find( 123 - (s) => s.uri === notification.data.subscription_uri, 124 - )!, 125 - })); 126 } 127 128 export type HydratedQuoteNotification = Awaited< ··· 153 .select("*, documents_in_publications(publications(*))") 154 .in("uri", documentUris); 155 156 - return quoteNotifications.map((notification) => ({ 157 - id: notification.id, 158 - recipient: notification.recipient, 159 - created_at: notification.created_at, 160 - type: "quote" as const, 161 - bsky_post_uri: notification.data.bsky_post_uri, 162 - document_uri: notification.data.document_uri, 163 - bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!, 164 - document: documents?.find((d) => d.uri === notification.data.document_uri)!, 165 - })); 166 } 167 168 export async function pingIdentityToUpdateNotification(did: string) {
··· 2 3 import { supabaseServerClient } from "supabase/serverClient"; 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 8 type NotificationRow = Tables<"notifications">; 9 ··· 14 export type NotificationData = 15 | { type: "comment"; comment_uri: string; parent_uri?: string } 16 | { type: "subscribe"; subscription_uri: string } 17 + | { type: "quote"; bsky_post_uri: string; document_uri: string } 18 + | { type: "mention"; document_uri: string; mention_type: "did" } 19 + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } 21 + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } 22 + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } 23 + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; 24 25 export type HydratedNotification = 26 | HydratedCommentNotification 27 | HydratedSubscribeNotification 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 31 export async function hydrateNotifications( 32 notifications: NotificationRow[], 33 ): Promise<Array<HydratedNotification>> { 34 // Call all hydrators in parallel 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 36 hydrateCommentNotifications(notifications), 37 hydrateSubscribeNotifications(notifications), 38 hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 41 ]); 42 43 // Combine all hydrated notifications 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 45 46 // Sort by created_at to maintain order 47 allHydrated.sort( ··· 85 ) 86 .in("uri", commentUris); 87 88 + return commentNotifications 89 + .map((notification) => { 90 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 91 + if (!commentData) return null; 92 + return { 93 + id: notification.id, 94 + recipient: notification.recipient, 95 + created_at: notification.created_at, 96 + type: "comment" as const, 97 + comment_uri: notification.data.comment_uri, 98 + parentData: notification.data.parent_uri 99 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 + : undefined, 101 + commentData, 102 + }; 103 + }) 104 + .filter((n) => n !== null); 105 } 106 107 export type HydratedSubscribeNotification = Awaited< ··· 129 .select("*, identities(bsky_profiles(*)), publications(*)") 130 .in("uri", subscriptionUris); 131 132 + return subscribeNotifications 133 + .map((notification) => { 134 + const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 135 + if (!subscriptionData) return null; 136 + return { 137 + id: notification.id, 138 + recipient: notification.recipient, 139 + created_at: notification.created_at, 140 + type: "subscribe" as const, 141 + subscription_uri: notification.data.subscription_uri, 142 + subscriptionData, 143 + }; 144 + }) 145 + .filter((n) => n !== null); 146 } 147 148 export type HydratedQuoteNotification = Awaited< ··· 173 .select("*, documents_in_publications(publications(*))") 174 .in("uri", documentUris); 175 176 + return quoteNotifications 177 + .map((notification) => { 178 + const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 179 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 180 + if (!bskyPost || !document) return null; 181 + return { 182 + id: notification.id, 183 + recipient: notification.recipient, 184 + created_at: notification.created_at, 185 + type: "quote" as const, 186 + bsky_post_uri: notification.data.bsky_post_uri, 187 + document_uri: notification.data.document_uri, 188 + bskyPost, 189 + document, 190 + }; 191 + }) 192 + .filter((n) => n !== null); 193 + } 194 + 195 + export type HydratedMentionNotification = Awaited< 196 + ReturnType<typeof hydrateMentionNotifications> 197 + >[0]; 198 + 199 + async function hydrateMentionNotifications(notifications: NotificationRow[]) { 200 + const mentionNotifications = notifications.filter( 201 + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => 202 + (n.data as NotificationData)?.type === "mention", 203 + ); 204 + 205 + if (mentionNotifications.length === 0) { 206 + return []; 207 + } 208 + 209 + // Fetch document data from the database 210 + const documentUris = mentionNotifications.map((n) => n.data.document_uri); 211 + const { data: documents } = await supabaseServerClient 212 + .from("documents") 213 + .select("*, documents_in_publications(publications(*))") 214 + .in("uri", documentUris); 215 + 216 + // Extract unique DIDs from document URIs to resolve handles 217 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 218 + 219 + // Resolve DIDs to handles in parallel 220 + const didToHandleMap = new Map<string, string | null>(); 221 + await Promise.all( 222 + documentCreatorDids.map(async (did) => { 223 + try { 224 + const resolved = await idResolver.did.resolve(did); 225 + const handle = resolved?.alsoKnownAs?.[0] 226 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 227 + : null; 228 + didToHandleMap.set(did, handle); 229 + } catch (error) { 230 + console.error(`Failed to resolve DID ${did}:`, error); 231 + didToHandleMap.set(did, null); 232 + } 233 + }), 234 + ); 235 + 236 + // Fetch mentioned publications and documents 237 + const mentionedPublicationUris = mentionNotifications 238 + .filter((n) => n.data.mention_type === "publication") 239 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri); 240 + 241 + const mentionedDocumentUris = mentionNotifications 242 + .filter((n) => n.data.mention_type === "document") 243 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri); 244 + 245 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 246 + mentionedPublicationUris.length > 0 247 + ? supabaseServerClient 248 + .from("publications") 249 + .select("*") 250 + .in("uri", mentionedPublicationUris) 251 + : Promise.resolve({ data: [] }), 252 + mentionedDocumentUris.length > 0 253 + ? supabaseServerClient 254 + .from("documents") 255 + .select("*, documents_in_publications(publications(*))") 256 + .in("uri", mentionedDocumentUris) 257 + : Promise.resolve({ data: [] }), 258 + ]); 259 + 260 + return mentionNotifications 261 + .map((notification) => { 262 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 263 + if (!document) return null; 264 + 265 + const mentionedUri = notification.data.mention_type !== "did" 266 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 267 + : undefined; 268 + 269 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 + 272 + return { 273 + id: notification.id, 274 + recipient: notification.recipient, 275 + created_at: notification.created_at, 276 + type: "mention" as const, 277 + document_uri: notification.data.document_uri, 278 + mention_type: notification.data.mention_type, 279 + mentioned_uri: mentionedUri, 280 + document, 281 + documentCreatorHandle, 282 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 284 + }; 285 + }) 286 + .filter((n) => n !== null); 287 + } 288 + 289 + export type HydratedCommentMentionNotification = Awaited< 290 + ReturnType<typeof hydrateCommentMentionNotifications> 291 + >[0]; 292 + 293 + async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { 294 + const commentMentionNotifications = notifications.filter( 295 + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => 296 + (n.data as NotificationData)?.type === "comment_mention", 297 + ); 298 + 299 + if (commentMentionNotifications.length === 0) { 300 + return []; 301 + } 302 + 303 + // Fetch comment data from the database 304 + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); 305 + const { data: comments } = await supabaseServerClient 306 + .from("comments_on_documents") 307 + .select( 308 + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 309 + ) 310 + .in("uri", commentUris); 311 + 312 + // Extract unique DIDs from comment URIs to resolve handles 313 + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 314 + 315 + // Resolve DIDs to handles in parallel 316 + const didToHandleMap = new Map<string, string | null>(); 317 + await Promise.all( 318 + commenterDids.map(async (did) => { 319 + try { 320 + const resolved = await idResolver.did.resolve(did); 321 + const handle = resolved?.alsoKnownAs?.[0] 322 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 323 + : null; 324 + didToHandleMap.set(did, handle); 325 + } catch (error) { 326 + console.error(`Failed to resolve DID ${did}:`, error); 327 + didToHandleMap.set(did, null); 328 + } 329 + }), 330 + ); 331 + 332 + // Fetch mentioned publications and documents 333 + const mentionedPublicationUris = commentMentionNotifications 334 + .filter((n) => n.data.mention_type === "publication") 335 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri); 336 + 337 + const mentionedDocumentUris = commentMentionNotifications 338 + .filter((n) => n.data.mention_type === "document") 339 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri); 340 + 341 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 342 + mentionedPublicationUris.length > 0 343 + ? supabaseServerClient 344 + .from("publications") 345 + .select("*") 346 + .in("uri", mentionedPublicationUris) 347 + : Promise.resolve({ data: [] }), 348 + mentionedDocumentUris.length > 0 349 + ? supabaseServerClient 350 + .from("documents") 351 + .select("*, documents_in_publications(publications(*))") 352 + .in("uri", mentionedDocumentUris) 353 + : Promise.resolve({ data: [] }), 354 + ]); 355 + 356 + return commentMentionNotifications 357 + .map((notification) => { 358 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 359 + if (!commentData) return null; 360 + 361 + const mentionedUri = notification.data.mention_type !== "did" 362 + ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 363 + : undefined; 364 + 365 + const commenterDid = new AtUri(notification.data.comment_uri).host; 366 + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 + 368 + return { 369 + id: notification.id, 370 + recipient: notification.recipient, 371 + created_at: notification.created_at, 372 + type: "comment_mention" as const, 373 + comment_uri: notification.data.comment_uri, 374 + mention_type: notification.data.mention_type, 375 + mentioned_uri: mentionedUri, 376 + commentData, 377 + commenterHandle, 378 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 380 + }; 381 + }) 382 + .filter((n) => n !== null); 383 } 384 385 export async function pingIdentityToUpdateNotification(did: string) {
+34 -8
src/replicache/mutations.ts
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 614 }> = async (args, ctx) => { 615 await ctx.runOnServer(async (serverCtx) => { 616 console.log("updating"); 617 - await serverCtx.supabase 618 - .from("leaflets_in_publications") 619 - .update({ description: args.description, title: args.title }) 620 - .eq("leaflet", ctx.permission_token_id); 621 }); 622 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 625 }); 626 }; 627
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 615 }> = async (args, ctx) => { 616 await ctx.runOnServer(async (serverCtx) => { 617 console.log("updating"); 618 + const updates: { 619 + description?: string; 620 + title?: string; 621 + tags?: string[]; 622 + } = {}; 623 + if (args.description !== undefined) updates.description = args.description; 624 + if (args.title !== undefined) updates.title = args.title; 625 + if (args.tags !== undefined) updates.tags = args.tags; 626 + 627 + if (Object.keys(updates).length > 0) { 628 + // First try to update leaflets_in_publications (for publications) 629 + const { data: pubResult } = await serverCtx.supabase 630 + .from("leaflets_in_publications") 631 + .update(updates) 632 + .eq("leaflet", ctx.permission_token_id) 633 + .select("leaflet"); 634 + 635 + // If no rows were updated in leaflets_in_publications, 636 + // try leaflets_to_documents (for standalone documents) 637 + if (!pubResult || pubResult.length === 0) { 638 + await serverCtx.supabase 639 + .from("leaflets_to_documents") 640 + .update(updates) 641 + .eq("leaflet", ctx.permission_token_id); 642 + } 643 + } 644 }); 645 await ctx.runOnClient(async ({ tx }) => { 646 + if (args.title !== undefined) 647 + await tx.set("publication_title", args.title); 648 + if (args.description !== undefined) 649 + await tx.set("publication_description", args.description); 650 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 651 }); 652 }; 653
+116
src/utils/deleteBlock.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + 8 + export async function deleteBlock( 9 + entities: string[], 10 + rep: Replicache<ReplicacheMutators>, 11 + ) { 12 + // get what pagess we need to close as a result of deleting this block 13 + let pagesToClose = [] as string[]; 14 + for (let entity of entities) { 15 + let [type] = await rep.query((tx) => 16 + scanIndex(tx).eav(entity, "block/type"), 17 + ); 18 + if (type.data.value === "card") { 19 + let [childPages] = await rep?.query( 20 + (tx) => scanIndex(tx).eav(entity, "block/card") || [], 21 + ); 22 + pagesToClose = [childPages?.data.value]; 23 + } 24 + if (type.data.value === "mailbox") { 25 + let [archive] = await rep?.query( 26 + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 27 + ); 28 + let [draft] = await rep?.query( 29 + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 30 + ); 31 + pagesToClose = [archive?.data.value, draft?.data.value]; 32 + } 33 + } 34 + 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 37 + let focusedBlock = useUIState.getState().focusedEntity; 38 + let parent = 39 + focusedBlock?.entityType === "page" 40 + ? focusedBlock.entityID 41 + : focusedBlock?.parent; 42 + 43 + if (parent) { 44 + let parentType = await rep?.query((tx) => 45 + scanIndex(tx).eav(parent, "page/type"), 46 + ); 47 + if (parentType[0]?.data.value === "canvas") { 48 + useUIState 49 + .getState() 50 + .setFocusedBlock({ entityType: "page", entityID: parent }); 51 + useUIState.getState().setSelectedBlocks([]); 52 + } else { 53 + let siblings = 54 + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 + 56 + let selectedBlocks = useUIState.getState().selectedBlocks; 57 + let firstSelected = selectedBlocks[0]; 58 + let lastSelected = selectedBlocks[entities.length - 1]; 59 + 60 + let prevBlock = 61 + siblings?.[ 62 + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 63 + ]; 64 + let prevBlockType = await rep?.query((tx) => 65 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 66 + ); 67 + 68 + let nextBlock = 69 + siblings?.[ 70 + siblings.findIndex((s) => s.value === lastSelected.value) + 1 71 + ]; 72 + let nextBlockType = await rep?.query((tx) => 73 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 74 + ); 75 + 76 + if (prevBlock) { 77 + useUIState.getState().setSelectedBlock({ 78 + value: prevBlock.value, 79 + parent: prevBlock.parent, 80 + }); 81 + 82 + focusBlock( 83 + { 84 + value: prevBlock.value, 85 + type: prevBlockType?.[0].data.value, 86 + parent: prevBlock.parent, 87 + }, 88 + { type: "end" }, 89 + ); 90 + } else { 91 + useUIState.getState().setSelectedBlock({ 92 + value: nextBlock.value, 93 + parent: nextBlock.parent, 94 + }); 95 + 96 + focusBlock( 97 + { 98 + value: nextBlock.value, 99 + type: nextBlockType?.[0]?.data.value, 100 + parent: nextBlock.parent, 101 + }, 102 + { type: "start" }, 103 + ); 104 + } 105 + } 106 + } 107 + 108 + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 + await Promise.all( 110 + entities.map((entity) => 111 + rep?.mutate.removeBlock({ 112 + blockEntity: entity, 113 + }), 114 + ), 115 + ); 116 + }
+37
src/utils/focusElement.ts
···
··· 1 + import { isIOS } from "src/utils/isDevice"; 2 + 3 + export const focusElement = ( 4 + el?: HTMLInputElement | HTMLTextAreaElement | null, 5 + ) => { 6 + if (!isIOS()) { 7 + el?.focus(); 8 + return; 9 + } 10 + 11 + let fakeInput = document.createElement("input"); 12 + fakeInput.setAttribute("type", "text"); 13 + fakeInput.style.position = "fixed"; 14 + fakeInput.style.height = "0px"; 15 + fakeInput.style.width = "0px"; 16 + fakeInput.style.fontSize = "16px"; // disable auto zoom 17 + document.body.appendChild(fakeInput); 18 + fakeInput.focus(); 19 + setTimeout(() => { 20 + if (!el) return; 21 + el.style.transform = "translateY(-2000px)"; 22 + el?.focus(); 23 + fakeInput.remove(); 24 + el.value = " "; 25 + el.setSelectionRange(1, 1); 26 + requestAnimationFrame(() => { 27 + if (el) { 28 + el.style.transform = ""; 29 + } 30 + }); 31 + setTimeout(() => { 32 + if (!el) return; 33 + el.value = ""; 34 + el.setSelectionRange(0, 0); 35 + }, 50); 36 + }, 20); 37 + };
+73
src/utils/focusPage.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export async function focusPage( 10 + pageID: string, 11 + rep: Replicache<ReplicacheMutators>, 12 + focusFirstBlock?: "focusFirstBlock", 13 + ) { 14 + // if this page is already focused, 15 + let focusedBlock = useUIState.getState().focusedEntity; 16 + // else set this page as focused 17 + useUIState.setState(() => ({ 18 + focusedEntity: { 19 + entityType: "page", 20 + entityID: pageID, 21 + }, 22 + })); 23 + 24 + setTimeout(async () => { 25 + //scroll to page 26 + 27 + scrollIntoViewIfNeeded( 28 + document.getElementById(elementId.page(pageID).container), 29 + false, 30 + "smooth", 31 + ); 32 + 33 + // if we asked that the function focus the first block, focus the first block 34 + if (focusFirstBlock === "focusFirstBlock") { 35 + let firstBlock = await rep.query(async (tx) => { 36 + let type = await scanIndex(tx).eav(pageID, "page/type"); 37 + let blocks = await scanIndex(tx).eav( 38 + pageID, 39 + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 40 + ); 41 + 42 + let firstBlock = blocks[0]; 43 + 44 + if (!firstBlock) { 45 + return null; 46 + } 47 + 48 + let blockType = ( 49 + await tx 50 + .scan< 51 + Fact<"block/type"> 52 + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 53 + .toArray() 54 + )[0]; 55 + 56 + if (!blockType) return null; 57 + 58 + return { 59 + value: firstBlock.data.value, 60 + type: blockType.data.value, 61 + parent: firstBlock.entity, 62 + position: firstBlock.data.position, 63 + }; 64 + }); 65 + 66 + if (firstBlock) { 67 + setTimeout(() => { 68 + focusBlock(firstBlock, { type: "start" }); 69 + }, 500); 70 + } 71 + } 72 + }, 50); 73 + }
+6 -1
src/utils/getMicroLinkOgImage.ts
··· 17 hostname = "leaflet.pub"; 18 } 19 let full_path = `${protocol}://${hostname}${path}`; 20 - return getWebpageImage(full_path, options); 21 } 22 23 export async function getWebpageImage( 24 url: string, 25 options?: { 26 width?: number; 27 height?: number; 28 deviceScaleFactor?: number; ··· 39 }, 40 body: JSON.stringify({ 41 url, 42 scrollPage: true, 43 addStyleTag: [ 44 {
··· 17 hostname = "leaflet.pub"; 18 } 19 let full_path = `${protocol}://${hostname}${path}`; 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 24 } 25 26 export async function getWebpageImage( 27 url: string, 28 options?: { 29 + setJavaScriptEnabled?: boolean; 30 width?: number; 31 height?: number; 32 deviceScaleFactor?: number; ··· 43 }, 44 body: JSON.stringify({ 45 url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 47 scrollPage: true, 48 addStyleTag: [ 49 {
+7 -31
src/utils/getPublicationMetadataFromLeafletData.ts
··· 32 (p) => p.leaflets_in_publications?.length, 33 )?.leaflets_in_publications?.[0]; 34 35 - // If not found, check for standalone documents (looseleafs) 36 - let standaloneDoc = data?.leaflets_to_documents; 37 - 38 - // Only use standaloneDoc if it exists and has meaningful data 39 - // (either published with a document, or saved as draft with a title) 40 - if ( 41 - !pubData && 42 - standaloneDoc && 43 - (standaloneDoc.document || standaloneDoc.title) 44 - ) { 45 // Transform standalone document data to match the expected format 46 pubData = { 47 ...standaloneDoc, 48 publications: null, // No publication for standalone docs 49 doc: standaloneDoc.document, 50 - leaflet: data.id, 51 }; 52 } 53 - 54 - // Also check nested permission tokens for looseleafs 55 - if (!pubData) { 56 - let nestedStandaloneDoc = 57 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 58 - (p) => 59 - p.leaflets_to_documents && 60 - (p.leaflets_to_documents.document || p.leaflets_to_documents.title), 61 - )?.leaflets_to_documents; 62 - 63 - if (nestedStandaloneDoc) { 64 - pubData = { 65 - ...nestedStandaloneDoc, 66 - publications: null, 67 - doc: nestedStandaloneDoc.document, 68 - leaflet: data.id, 69 - }; 70 - } 71 - } 72 - 73 return pubData; 74 }
··· 32 (p) => p.leaflets_in_publications?.length, 33 )?.leaflets_in_publications?.[0]; 34 35 + // If not found, check for standalone documents 36 + let standaloneDoc = 37 + data?.leaflets_to_documents?.[0] || 38 + data?.permission_token_rights[0].entity_sets?.permission_tokens.find( 39 + (p) => p.leaflets_to_documents?.length, 40 + )?.leaflets_to_documents?.[0]; 41 + if (!pubData && standaloneDoc) { 42 // Transform standalone document data to match the expected format 43 pubData = { 44 ...standaloneDoc, 45 publications: null, // No publication for standalone docs 46 doc: standaloneDoc.document, 47 }; 48 } 49 return pubData; 50 }
+59
src/utils/mentionUtils.ts
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + 3 + /** 4 + * Converts a DID to a Bluesky profile URL 5 + */ 6 + export function didToBlueskyUrl(did: string): string { 7 + return `https://bsky.app/profile/${did}`; 8 + } 9 + 10 + /** 11 + * Converts an AT URI (publication or document) to the appropriate URL 12 + */ 13 + export function atUriToUrl(atUri: string): string { 14 + try { 15 + const uri = new AtUri(atUri); 16 + 17 + if (uri.collection === "pub.leaflet.publication") { 18 + // Publication URL: /lish/{did}/{rkey} 19 + return `/lish/${uri.host}/${uri.rkey}`; 20 + } else if (uri.collection === "pub.leaflet.document") { 21 + // Document URL - we need to resolve this via the API 22 + // For now, create a redirect route that will handle it 23 + return `/lish/uri/${encodeURIComponent(atUri)}`; 24 + } 25 + 26 + return "#"; 27 + } catch (e) { 28 + console.error("Failed to parse AT URI:", atUri, e); 29 + return "#"; 30 + } 31 + } 32 + 33 + /** 34 + * Opens a mention link in the appropriate way 35 + * - DID mentions open in a new tab (external Bluesky) 36 + * - Publication/document mentions navigate in the same tab 37 + */ 38 + export function handleMentionClick( 39 + e: MouseEvent | React.MouseEvent, 40 + type: "did" | "at-uri", 41 + value: string 42 + ) { 43 + e.preventDefault(); 44 + e.stopPropagation(); 45 + 46 + if (type === "did") { 47 + // Open Bluesky profile in new tab 48 + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); 49 + } else { 50 + // Navigate to publication/document in same tab 51 + const url = atUriToUrl(value); 52 + if (url.startsWith("/lish/uri/")) { 53 + // Redirect route - navigate to it 54 + window.location.href = url; 55 + } else { 56 + window.location.href = url; 57 + } 58 + } 59 + }
+41
src/utils/yjsFragmentToString.ts
···
··· 1 + import { XmlElement, XmlText, XmlHook } from "yjs"; 2 + 3 + export type Delta = { 4 + insert: string; 5 + attributes?: { 6 + strong?: {}; 7 + code?: {}; 8 + em?: {}; 9 + underline?: {}; 10 + strikethrough?: {}; 11 + highlight?: { color: string }; 12 + link?: { href: string }; 13 + }; 14 + }; 15 + 16 + export function YJSFragmentToString( 17 + node: XmlElement | XmlText | XmlHook, 18 + ): string { 19 + if (node.constructor === XmlElement) { 20 + // Handle hard_break nodes specially 21 + if (node.nodeName === "hard_break") { 22 + return "\n"; 23 + } 24 + // Handle inline mention nodes 25 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 26 + return node.getAttribute("text") || ""; 27 + } 28 + return node 29 + .toArray() 30 + .map((f) => YJSFragmentToString(f)) 31 + .join(""); 32 + } 33 + if (node.constructor === XmlText) { 34 + return (node.toDelta() as Delta[]) 35 + .map((d) => { 36 + return d.insert; 37 + }) 38 + .join(""); 39 + } 40 + return ""; 41 + }
+13 -4
supabase/database.types.ts
··· 631 Row: { 632 created_at: string 633 description: string 634 - document: string | null 635 leaflet: string 636 title: string 637 } 638 Insert: { 639 created_at?: string 640 description?: string 641 - document?: string | null 642 leaflet: string 643 title?: string 644 } 645 Update: { 646 created_at?: string 647 description?: string 648 - document?: string | null 649 leaflet?: string 650 title?: string 651 } ··· 660 { 661 foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 columns: ["leaflet"] 663 - isOneToOne: true 664 referencedRelation: "permission_tokens" 665 referencedColumns: ["id"] 666 }, ··· 1157 client_group_id: string 1158 } 1159 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1160 } 1161 } 1162 Enums: {
··· 631 Row: { 632 created_at: string 633 description: string 634 + document: string 635 leaflet: string 636 title: string 637 } 638 Insert: { 639 created_at?: string 640 description?: string 641 + document: string 642 leaflet: string 643 title?: string 644 } 645 Update: { 646 created_at?: string 647 description?: string 648 + document?: string 649 leaflet?: string 650 title?: string 651 } ··· 660 { 661 foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 columns: ["leaflet"] 663 + isOneToOne: false 664 referencedRelation: "permission_tokens" 665 referencedColumns: ["id"] 666 }, ··· 1157 client_group_id: string 1158 } 1159 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1160 + } 1161 + search_tags: { 1162 + Args: { 1163 + search_query: string 1164 + } 1165 + Returns: { 1166 + name: string 1167 + document_count: number 1168 + }[] 1169 } 1170 } 1171 Enums: {
+30
supabase/migrations/20251204120000_add_tags_support.sql
···
··· 1 + -- Create GIN index on the tags array in the JSONB data field 2 + -- This allows efficient querying of documents by tag 3 + CREATE INDEX IF NOT EXISTS idx_documents_tags 4 + ON "public"."documents" USING gin ((data->'tags')); 5 + 6 + -- Function to search and aggregate tags from documents 7 + -- This does the aggregation in the database rather than fetching all documents 8 + CREATE OR REPLACE FUNCTION search_tags(search_query text) 9 + RETURNS TABLE (name text, document_count bigint) AS $$ 10 + BEGIN 11 + RETURN QUERY 12 + SELECT 13 + LOWER(tag::text) as name, 14 + COUNT(DISTINCT d.uri) as document_count 15 + FROM 16 + "public"."documents" d, 17 + jsonb_array_elements_text(d.data->'tags') as tag 18 + WHERE 19 + CASE 20 + WHEN search_query = '' THEN true 21 + ELSE LOWER(tag::text) LIKE '%' || search_query || '%' 22 + END 23 + GROUP BY 24 + LOWER(tag::text) 25 + ORDER BY 26 + COUNT(DISTINCT d.uri) DESC, 27 + LOWER(tag::text) ASC 28 + LIMIT 20; 29 + END; 30 + $$ LANGUAGE plpgsql STABLE;
+7
supabase/migrations/20251204130000_add_tags_to_drafts.sql
···
··· 1 + -- Add tags column to leaflets_in_publications for publication drafts 2 + ALTER TABLE "public"."leaflets_in_publications" 3 + ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[]; 4 + 5 + -- Add tags column to leaflets_to_documents for standalone document drafts 6 + ALTER TABLE "public"."leaflets_to_documents" 7 + ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[];
+38
supabase/migrations/20251204140000_update_pull_data_with_tags.sql
···
··· 1 + set check_function_bodies = off; 2 + 3 + CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text) 4 + RETURNS pull_result 5 + LANGUAGE plpgsql 6 + AS $function$DECLARE 7 + result pull_result; 8 + BEGIN 9 + -- Get client group data as JSON array 10 + SELECT json_agg(row_to_json(rc)) 11 + FROM replicache_clients rc 12 + WHERE rc.client_group = client_group_id 13 + INTO result.client_groups; 14 + 15 + -- Get facts as JSON array 16 + SELECT json_agg(row_to_json(f)) 17 + FROM permission_tokens pt, 18 + get_facts(pt.root_entity) f 19 + WHERE pt.id = token_id 20 + INTO result.facts; 21 + 22 + -- Get publication data - try leaflets_in_publications first, then leaflets_to_documents 23 + SELECT json_agg(row_to_json(lip)) 24 + FROM leaflets_in_publications lip 25 + WHERE lip.leaflet = token_id 26 + INTO result.publications; 27 + 28 + -- If no publication data found, try leaflets_to_documents (for standalone documents) 29 + IF result.publications IS NULL THEN 30 + SELECT json_agg(row_to_json(ltd)) 31 + FROM leaflets_to_documents ltd 32 + WHERE ltd.leaflet = token_id 33 + INTO result.publications; 34 + END IF; 35 + 36 + RETURN result; 37 + END;$function$ 38 + ;