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 53 } 54 54 55 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) { 56 + const leafletDocs = tokenData.leaflets_to_documents || []; 57 + if (leafletDocs.length > 0) { 58 + if (!identity) { 59 59 throw new Error( 60 60 "Unauthorized: You must be logged in to delete a published leaflet", 61 61 ); 62 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 - ); 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 + } 69 71 } 70 72 } 71 73 } ··· 81 83 .where(eq(permission_tokens.id, permission_token.id)); 82 84 83 85 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)); 86 + await tx 87 + .delete(entities) 88 + .where(eq(entities.set, token.permission_token_rights.entity_set)); 87 89 await tx 88 90 .delete(permission_tokens) 89 91 .where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
··· 11 11 ) { 12 12 let identity = await getIdentityData(); 13 13 if (!identity || !identity.atp_did) return null; 14 - 15 - // Verify publication ownership 16 14 let { data: publication } = await supabaseServerClient 17 15 .from("publications") 18 16 .select("*") ··· 20 18 .single(); 21 19 if (publication?.identity_did !== identity.atp_did) return; 22 20 23 - // Save as a publication draft 24 21 await supabaseServerClient.from("leaflets_in_publications").insert({ 25 22 publication: publication_uri, 26 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 32 import { scanIndexLocal } from "src/replicache/utils"; 33 33 import type { Fact } from "src/replicache"; 34 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 36 import { ids } from "lexicons/api/lexicons"; 40 37 import { BlobRef } from "@atproto/lexicon"; 41 38 import { AtUri } from "@atproto/syntax"; ··· 50 47 ColorToRGBA, 51 48 } from "components/ThemeManager/colorToLexicons"; 52 49 import { parseColor } from "@react-stately/color"; 50 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 + import { v7 } from "uuid"; 53 52 54 53 export async function publishToPublication({ 55 54 root_entity, ··· 57 56 leaflet_id, 58 57 title, 59 58 description, 59 + tags, 60 60 entitiesToDelete, 61 61 }: { 62 62 root_entity: string; ··· 64 64 leaflet_id: string; 65 65 title?: string; 66 66 description?: string; 67 + tags?: string[]; 67 68 entitiesToDelete?: string[]; 68 69 }) { 69 70 const oauthClient = await createOauthClient(); ··· 143 144 ...(theme && { theme }), 144 145 title: title || "Untitled", 145 146 description: description || "", 147 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 146 148 pages: pages.map((p) => { 147 149 if (p.type === "canvas") { 148 150 return { ··· 210 212 } 211 213 } 212 214 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 218 + } 219 + 213 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 214 221 } 215 222 ··· 298 305 if (!b) return []; 299 306 let block: PubLeafletPagesLinearDocument.Block = { 300 307 $type: "pub.leaflet.pages.linearDocument#block", 301 - alignment, 302 308 block: b, 303 309 }; 310 + if (alignment) block.alignment = alignment; 304 311 return [block]; 305 312 } else { 306 313 let block: PubLeafletPagesLinearDocument.Block = { ··· 342 349 Y.applyUpdate(doc, update); 343 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 344 351 let stringValue = YJSFragmentToString(nodes[0]); 345 - let facets = YJSFragmentToFacets(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 346 353 return [stringValue, facets] as const; 347 354 }; 348 355 if (b.type === "card") { ··· 398 405 let [stringValue, facets] = getBlockContent(b.value); 399 406 let block: $Typed<PubLeafletBlocksHeader.Main> = { 400 407 $type: "pub.leaflet.blocks.header", 401 - level: headingLevel?.data.value || 1, 408 + level: Math.floor(headingLevel?.data.value || 1), 402 409 plaintext: stringValue, 403 410 facets, 404 411 }; ··· 431 438 let block: $Typed<PubLeafletBlocksIframe.Main> = { 432 439 $type: "pub.leaflet.blocks.iframe", 433 440 url: url.data.value, 434 - height: height?.data.value || 600, 441 + height: Math.floor(height?.data.value || 600), 435 442 }; 436 443 return block; 437 444 } ··· 445 452 $type: "pub.leaflet.blocks.image", 446 453 image: blobref, 447 454 aspectRatio: { 448 - height: image.data.height, 449 - width: image.data.width, 455 + height: Math.floor(image.data.height), 456 + width: Math.floor(image.data.width), 450 457 }, 451 458 alt: altText ? altText.data.value : undefined, 452 459 }; ··· 603 610 604 611 function YJSFragmentToFacets( 605 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 606 - ): PubLeafletRichtextFacet.Main[] { 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 607 615 if (node.constructor === Y.XmlElement) { 608 - return node 609 - .toArray() 610 - .map((f) => YJSFragmentToFacets(f)) 611 - .flat(); 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 }; 612 667 } 668 + 613 669 if (node.constructor === Y.XmlText) { 614 670 let facets: PubLeafletRichtextFacet.Main[] = []; 615 671 let delta = node.toDelta() as Delta[]; 616 - let byteStart = 0; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 617 674 for (let d of delta) { 618 675 let unicodestring = new UnicodeString(d.insert); 619 676 let facet: PubLeafletRichtextFacet.Main = { ··· 646 703 }); 647 704 if (facet.features.length > 0) facets.push(facet); 648 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 649 707 } 650 - return facets; 708 + return { facets, byteLength: totalLength }; 651 709 } 652 - return []; 710 + return { facets: [], byteLength: 0 }; 653 711 } 654 712 655 713 type ExcludeString<T> = T extends string ··· 712 770 image: blob.data.blob, 713 771 repeat: backgroundImageRepeat?.data.value ? true : false, 714 772 ...(backgroundImageRepeat?.data.value && { 715 - width: backgroundImageRepeat.data.value, 773 + width: Math.floor(backgroundImageRepeat.data.value), 716 774 }), 717 775 }; 718 776 } ··· 725 783 726 784 return undefined; 727 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 11 import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types"; 13 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 15 import { pool } from "supabase/pool"; 16 16 17 17 let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 4 5 import { PubIcon } from "components/ActionBar/Publications"; 5 6 import { Separator } from "components/Layout"; 6 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+7 -92
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 1 1 "use client"; 2 2 3 - import { Action } from "@vercel/sdk/esm/models/userevent"; 4 3 import { createNewLeaflet } from "actions/createNewLeaflet"; 5 4 import { ActionButton } from "components/ActionBar/ActionButton"; 6 5 import { AddTiny } from "components/Icons/AddTiny"; 7 - import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 8 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 9 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 10 - import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 11 - import { Menu, MenuItem, Separator } from "components/Layout"; 8 + import { Menu, MenuItem } from "components/Layout"; 12 9 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 10 20 11 export const CreateNewLeafletButton = (props: {}) => { 21 12 let isMobile = useIsMobile(); ··· 27 18 } 28 19 }; 29 20 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 21 <Menu 65 22 asChild 66 23 side={isMobile ? "top" : "right"} 67 24 align={isMobile ? "center" : "start"} 68 - className="py-2" 69 25 trigger={ 70 26 <ActionButton 71 - id="new-leaflet-more-options" 27 + id="new-leaflet-button" 72 28 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!" 29 + icon=<AddTiny className="m-1 shrink-0" /> 30 + label="New" 75 31 /> 76 32 } 77 33 > 78 34 <MenuItem 79 - className="leading-snug" 80 35 onSelect={async () => { 81 36 let id = await createNewLeaflet({ 82 37 pageType: "doc", ··· 85 40 openNewLeaflet(id); 86 41 }} 87 42 > 88 - <BlockDocPageSmall /> 43 + <BlockDocPageSmall />{" "} 89 44 <div className="flex flex-col"> 90 - <div>Doc</div> 45 + <div>New Doc</div> 91 46 <div className="text-tertiary text-sm font-normal"> 92 47 A good ol&apos; text document 93 48 </div> 94 49 </div> 95 50 </MenuItem> 96 51 <MenuItem 97 - className="leading-snug" 98 52 onSelect={async () => { 99 53 let id = await createNewLeaflet({ 100 54 pageType: "canvas", ··· 105 59 > 106 60 <BlockCanvasPageSmall /> 107 61 <div className="flex flex-col"> 108 - Canvas 62 + New Canvas 109 63 <div className="text-tertiary text-sm font-normal"> 110 64 A digital whiteboard 111 65 </div> 112 66 </div> 113 67 </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 68 </Menu> 154 69 ); 155 70 };
+10 -14
app/(home-pages)/home/HomeLayout.tsx
··· 29 29 HomeEmptyState, 30 30 PublicationBanner, 31 31 } from "./HomeEmpty/HomeEmpty"; 32 - import { EmptyState } from "components/EmptyState"; 33 32 34 33 export type Leaflet = { 35 34 added_at: string; ··· 136 135 (acc, tok) => { 137 136 let title = 138 137 tok.permission_tokens.leaflets_in_publications[0]?.title || 139 - tok.permission_tokens.leaflets_to_documents?.title; 138 + tok.permission_tokens.leaflets_to_documents[0]?.title; 140 139 if (title) acc[tok.permission_tokens.root_entity] = title; 141 140 return acc; 142 141 }, ··· 212 211 className={` 213 212 leafletList 214 213 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 "} `} 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"} `} 216 215 > 217 - {searchedLeaflets.length === 0 && ( 218 - <EmptyState> 219 - <div className="italic">Oh no! No results!</div> 220 - </EmptyState> 221 - )} 222 216 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 223 217 <ReplicacheProvider 224 218 disablePull ··· 233 227 value={{ 234 228 ...leaflet, 235 229 leaflets_in_publications: leaflet.leaflets_in_publications || [], 236 - leaflets_to_documents: leaflet.leaflets_to_documents || null, 230 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 237 231 blocked_by_admin: null, 238 232 custom_domain_routes: [], 239 233 }} ··· 292 286 ({ token: leaflet, archived: archived }) => { 293 287 let published = 294 288 !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 295 - !!leaflet.leaflets_to_documents?.document; 289 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 296 290 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 291 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 - // If no filters are active, show all 292 + 293 + // If no filters are active, show everything that is not archived 299 294 if ( 300 295 !filter.drafts && 301 296 !filter.published && ··· 304 299 ) 305 300 return archived === false || archived === null || archived == undefined; 306 301 302 + //if a filter is on, return itemsd of that filter that are also NOT archived 307 303 return ( 308 - (filter.drafts && drafts) || 309 - (filter.published && published) || 310 - (filter.docs && docs) || 304 + (filter.drafts && drafts && !archived) || 305 + (filter.published && published && !archived) || 306 + (filter.docs && docs && !archived) || 311 307 (filter.archived && archived) 312 308 ); 313 309 },
+1 -1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 108 108 await archivePost(tokenId); 109 109 toaster({ 110 110 content: ( 111 - <div className="font-bold flex gap-2"> 111 + <div className="font-bold flex gap-2 items-center"> 112 112 Archived {itemType}! 113 113 <ButtonTertiary 114 114 className="underline text-accent-2!"
+1 -1
app/(home-pages)/home/page.tsx
··· 30 30 (acc, tok) => { 31 31 let title = 32 32 tok.permission_tokens.leaflets_in_publications[0]?.title || 33 - tok.permission_tokens.leaflets_to_documents?.title; 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 34 34 if (title) acc[tok.permission_tokens.root_entity] = title; 35 35 return acc; 36 36 },
+5 -50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 1 "use client"; 2 - import { 3 - DashboardLayout, 4 - PublicationDashboardControls, 5 - } from "components/PageLayouts/DashboardLayout"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 6 3 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 4 import { useState } from "react"; 8 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 14 11 import useSWR from "swr"; 15 12 import { getHomeDocs } from "../home/storage"; 16 13 import { Leaflet, LeafletList } from "../home/HomeLayout"; 17 - import { EmptyState } from "components/EmptyState"; 18 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 19 14 20 15 export const LooseleafsLayout = (props: { 21 16 entityID: string | null; ··· 41 36 id="looseleafs" 42 37 cardBorderHidden={cardBorderHidden} 43 38 currentPage="looseleafs" 44 - defaultTab="Drafts" 39 + defaultTab="home" 45 40 actions={<Actions />} 46 41 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: { 42 + home: { 59 43 controls: null, 60 44 content: ( 61 45 <LooseleafList ··· 71 55 ); 72 56 }; 73 57 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 58 export const LooseleafList = (props: { 91 59 titles: { [root_entity: string]: string }; 92 60 initialFacts: { ··· 111 79 (acc, tok) => { 112 80 let title = 113 81 tok.permission_tokens.leaflets_in_publications[0]?.title || 114 - tok.permission_tokens.leaflets_to_documents?.title; 82 + tok.permission_tokens.leaflets_to_documents[0]?.title; 115 83 if (title) acc[tok.permission_tokens.root_entity] = title; 116 84 return acc; 117 85 }, ··· 127 95 let leaflets: Leaflet[] = identity 128 96 ? identity.permission_token_on_homepage 129 97 .filter( 130 - (ptoh) => 131 - ptoh.permission_tokens.leaflets_to_documents && 132 - ptoh.permission_tokens.leaflets_to_documents.document, 98 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 133 99 ) 134 100 .map((ptoh) => ({ 135 101 added_at: ptoh.created_at, 136 102 token: ptoh.permission_tokens as PermissionToken, 137 103 })) 138 104 : []; 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 105 return ( 151 106 <LeafletList 152 107 defaultDisplay="list"
+1 -1
app/(home-pages)/looseleafs/page.tsx
··· 34 34 (acc, tok) => { 35 35 let title = 36 36 tok.permission_tokens.leaflets_in_publications[0]?.title || 37 - tok.permission_tokens.leaflets_to_documents?.title; 37 + tok.permission_tokens.leaflets_to_documents[0]?.title; 38 38 if (title) acc[tok.permission_tokens.root_entity] = title; 39 39 return acc; 40 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"; 1 + import { MentionTiny } from "components/Icons/MentionTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 - import { HydratedQuoteNotification } from "src/notifications"; 3 + import { HydratedMentionNotification } from "src/notifications"; 4 4 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { AtUri } from "@atproto/api"; 6 - import { Avatar } from "components/Avatar"; 5 + import { Agent, AtUri } from "@atproto/api"; 7 6 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"; 7 + export const MentionNotification = (props: HydratedMentionNotification) => { 12 8 const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 9 + const pubRecord = props.document.documents_in_publications?.[0]?.publications 14 10 ?.record as PubLeafletPublication.Record | undefined; 15 11 const docUri = new AtUri(props.document.uri); 16 12 const rkey = docUri.rkey; 17 13 const did = docUri.host; 18 - const postText = postView.record?.text || ""; 19 14 20 15 const href = pubRecord 21 16 ? `https://${pubRecord.base_path}/${rkey}` 22 17 : `/p/${did}/${rkey}`; 23 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 + 24 55 return ( 25 56 <Notification 26 57 timestamp={props.created_at} 27 58 href={href} 28 - icon={<QuoteTiny />} 29 - actionText={<>{displayName} quoted your post</>} 59 + icon={<MentionTiny />} 60 + actionText={actionText} 30 61 content={ 31 62 <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> 63 + {docRecord.description && docRecord.description} 44 64 </ContentLayout> 45 65 } 46 66 />
+3 -3
app/(home-pages)/notifications/Notification.tsx
··· 69 69 <div 70 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 71 > 72 - <div className="text-tertiary text-sm italic font-bold pb-1"> 72 + <div className="text-tertiary text-sm italic font-bold "> 73 73 {props.postTitle} 74 74 </div> 75 - {props.children} 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 76 {props.pubRecord && ( 77 77 <> 78 - <hr className="mt-3 mb-1 border-border-light" /> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 79 <a 80 80 href={`https://${props.pubRecord.base_path}`} 81 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 7 import { ReplyNotification } from "./ReplyNotification"; 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 - import { QuoteNotification } from "./MentionNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 11 13 12 14 export function NotificationList({ 13 15 notifications, ··· 45 47 } 46 48 if (n.type === "quote") { 47 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} />; 48 56 } 49 57 })} 50 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 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 2 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 3 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 4 import type { Cursor, Post } from "./getReaderFeed"; 18 5 import useSWRInfinite from "swr/infinite"; 19 6 import { getReaderFeed } from "./getReaderFeed"; 20 7 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 8 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 24 - import { EmptyState } from "components/EmptyState"; 9 + import { PostListing } from "components/PostListing"; 25 10 26 11 export const ReaderContent = (props: { 27 12 posts: Post[]; ··· 29 14 }) => { 30 15 const getKey = ( 31 16 pageIndex: number, 32 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 33 21 ) => { 34 22 // Reached the end 35 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 41 29 return ["reader-feed", previousPageData?.nextCursor] as const; 42 30 }; 43 31 44 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 45 33 getKey, 46 34 ([_, cursor]) => getReaderFeed(cursor), 47 35 { ··· 80 68 return ( 81 69 <div className="flex flex-col gap-3 relative"> 82 70 {allPosts.map((p) => ( 83 - <Post {...p} key={p.documents.uri} /> 71 + <PostListing {...p} key={p.documents.uri} /> 84 72 ))} 85 73 {/* Trigger element for loading more posts */} 86 74 <div ··· 97 85 ); 98 86 }; 99 87 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 88 export const ReaderEmpty = () => { 275 89 return ( 276 - <EmptyState> 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"> 277 91 Nothing to read yetโ€ฆ <br /> 278 92 Subscribe to publications and find their posts here! 279 93 <Link href={"/discover"}> ··· 281 95 <DiscoverSmall /> Discover Publications 282 96 </ButtonPrimary> 283 97 </Link> 284 - </EmptyState> 98 + </div> 285 99 ); 286 100 };
+2 -3
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 8 8 import { useEffect, useRef } from "react"; 9 9 import { Cursor } from "./getReaderFeed"; 10 10 import Link from "next/link"; 11 - import { EmptyState } from "components/EmptyState"; 12 11 13 12 export const SubscriptionsContent = (props: { 14 13 publications: PublicationSubscription[]; ··· 94 93 95 94 export const SubscriptionsEmpty = () => { 96 95 return ( 97 - <EmptyState> 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"> 98 97 You haven't subscribed to any publications yet! 99 98 <Link href={"/discover"}> 100 99 <ButtonPrimary className="mx-auto place-self-center"> 101 100 <DiscoverSmall /> Discover Publications 102 101 </ButtonPrimary> 103 102 </Link> 104 - </EmptyState> 103 + </div> 105 104 ); 106 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 53 archived: null, 54 54 permission_tokens: { 55 55 ...permission_token, 56 - leaflets_to_documents: null, 56 + leaflets_to_documents: [], 57 57 leaflets_in_publications: [], 58 58 }, 59 59 });
+15 -22
app/[leaflet_id]/actions/PublishButton.tsx
··· 27 27 import { useState, useMemo } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; 29 29 import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 30 31 import { Json } from "supabase/database.types"; 31 32 import { 32 33 useBlocks, ··· 34 35 } from "src/hooks/queries/useBlocks"; 35 36 import * as Y from "yjs"; 36 37 import * as base64 from "base64-js"; 37 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 38 39 import { BlueskyLogin } from "app/login/LoginForm"; 39 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 40 - import { saveLeafletDraft } from "actions/publications/saveLeafletDraft"; 41 41 import { AddTiny } from "components/Icons/AddTiny"; 42 42 43 43 export const PublishButton = (props: { entityID: string }) => { ··· 64 64 const UpdateButton = () => { 65 65 let [isLoading, setIsLoading] = useState(false); 66 66 let { data: pub, mutate } = useLeafletPublicationData(); 67 - let { permission_token, rootEntity } = useReplicache(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 68 68 let { identity } = useIdentityData(); 69 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 : []; 70 74 71 75 return ( 72 76 <ActionButton ··· 82 86 leaflet_id: permission_token.id, 83 87 title: pub.title, 84 88 description: pub.description, 89 + tags: currentTags, 85 90 }); 86 91 setIsLoading(false); 87 92 mutate(); ··· 109 114 let { identity } = useIdentityData(); 110 115 let { permission_token } = useReplicache(); 111 116 let query = useSearchParams(); 112 - console.log(query.get("publish")); 113 117 let [open, setOpen] = useState(query.get("publish") !== null); 114 118 115 119 let isMobile = useIsMobile(); ··· 177 181 <hr className="border-border-light mt-3 mb-2" /> 178 182 179 183 <div className="flex gap-2 items-center place-self-end"> 180 - {selectedPub && selectedPub !== "create" && ( 184 + {selectedPub !== "looseleaf" && selectedPub && ( 181 185 <SaveAsDraftButton 182 186 selectedPub={selectedPub} 183 187 leafletId={permission_token.id} ··· 230 234 if (props.selectedPub === "create") return; 231 235 e.preventDefault(); 232 236 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 - 237 + await moveLeafletToPublication( 238 + props.leafletId, 239 + props.selectedPub, 240 + props.metadata, 241 + props.entitiesToDelete, 242 + ); 250 243 await Promise.all([rep?.pull(), mutate()]); 251 244 setIsLoading(false); 252 245 }}
+1 -1
app/[leaflet_id]/page.tsx
··· 4 4 5 5 import type { Fact } from "src/replicache"; 6 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 8 import { Leaflet } from "./Leaflet"; 9 9 import { scanIndexLocal } from "src/replicache/utils"; 10 10 import { getRSVPData } from "actions/getRSVPData";
+144 -294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 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"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 14 5 import { EditorView } from "prosemirror-view"; 15 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 7 import { baseKeymap } from "prosemirror-commands"; ··· 19 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 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"; 22 15 23 16 // Schema with only links, mentions, and hashtags marks 24 17 const bskyPostSchema = new Schema({ ··· 134 127 return tr; 135 128 }); 136 129 } 137 - 138 130 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 131 + editorStateRef: React.RefObject<EditorState | null>; 140 132 initialContent?: string; 141 133 onCharCountChange?: (count: number) => void; 142 134 }) { 143 135 const mountRef = useRef<HTMLDivElement | null>(null); 144 136 const viewRef = useRef<EditorView | null>(null); 145 137 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 }); 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 + }, []); 151 170 152 171 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 157 - if (!viewRef.current) return; 172 + (mention: Mention) => { 173 + if (!viewRef.current || mentionInsertPos === null) return; 158 174 const view = viewRef.current; 159 - const { from, to } = range; 175 + const from = mentionInsertPos - 1; 176 + const to = mentionInsertPos; 160 177 const tr = view.state.tr; 161 178 162 - // Delete the query text (keep the @) 163 - tr.delete(from + 1, to); 179 + // Delete the @ symbol 180 + tr.delete(from, to); 164 181 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); 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 + } 178 213 179 214 view.dispatch(tr); 180 215 view.focus(); 181 216 }, 182 - [], 217 + [mentionInsertPos], 183 218 ); 184 219 185 - const mentionStateRef = useRef(mentionState); 186 - mentionStateRef.current = mentionState; 220 + const handleMentionOpenChange = useCallback((open: boolean) => { 221 + setMentionOpen(open); 222 + if (!open) { 223 + setMentionCoords(null); 224 + setMentionInsertPos(null); 225 + } 226 + }, []); 187 227 188 228 useLayoutEffect(() => { 189 229 if (!mountRef.current) return; 190 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 + 191 240 const initialState = EditorState.create({ 192 241 schema: bskyPostSchema, 193 242 doc: props.initialContent ··· 200 249 }) 201 250 : undefined, 202 251 plugins: [ 203 - inputRules({ rules: [createHashtagInputRule()] }), 252 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 204 253 keymap({ 205 254 "Mod-z": undo, 206 255 "Mod-y": redo, 207 256 "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 257 }), 226 258 keymap(baseKeymap), 227 259 autolink({ ··· 258 290 view.destroy(); 259 291 viewRef.current = null; 260 292 }; 261 - }, [handleMentionSelect]); 293 + }, [openMentionAutocomplete]); 262 294 263 295 return ( 264 296 <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 - )} 297 + <MentionAutocomplete 298 + open={mentionOpen} 299 + onOpenChange={handleMentionOpenChange} 300 + view={viewRef} 301 + onSelect={handleMentionSelect} 302 + coords={mentionCoords} 303 + placeholder="Search people..." 304 + /> 275 305 {editorState?.doc.textContent.length === 0 && ( 276 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 277 307 Write a post to share your writing! ··· 279 309 )} 280 310 <div 281 311 ref={mountRef} 282 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 283 313 style={{ 284 314 wordWrap: "break-word", 285 315 overflowWrap: "break-word", ··· 290 320 ); 291 321 } 292 322 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 323 /** 515 324 * Converts a ProseMirror editor state to Bluesky post facets. 516 325 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 595 404 596 405 return features; 597 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 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 9 + 10 10 import { PubLeafletPublication } from "lexicons/api"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 16 17 import { 17 18 BlueskyPostEditorProsemirror, 18 19 editorStateToFacetedText, 19 20 } from "./BskyPostEditorProsemirror"; 20 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 21 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 24 import { PubIcon } from "components/ActionBar/Publications"; 23 25 ··· 31 33 record?: PubLeafletPublication.Record; 32 34 posts_in_pub?: number; 33 35 entitiesToDelete?: string[]; 36 + hasDraft: boolean; 34 37 }; 35 38 36 39 export function PublishPost(props: Props) { ··· 38 41 { state: "default" } | { state: "success"; post_url: string } 39 42 >({ state: "default" }); 40 43 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 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"> 42 45 {publishState.state === "default" ? ( 43 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 47 ) : ( ··· 58 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 62 } & Props, 60 63 ) => { 64 + let editorStateRef = useRef<EditorState | null>(null); 65 + let [charCount, setCharCount] = useState(0); 61 66 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 67 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 65 68 let params = useParams(); 66 69 let { rep } = useReplicache(); 67 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 + 68 96 async function submit() { 69 97 if (isLoading) return; 70 98 setIsLoading(true); ··· 75 103 leaflet_id: props.leaflet_id, 76 104 title: props.title, 77 105 description: props.description, 106 + tags: currentTags, 78 107 entitiesToDelete: props.entitiesToDelete, 79 108 }); 80 109 if (!doc) return; ··· 109 138 submit(); 110 139 }} 111 140 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 113 142 <PublishingTo 114 143 publication_uri={props.publication_uri} 115 144 record={props.record} 116 145 /> 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> 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 + /> 195 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 196 165 <div className="flex justify-between"> 197 166 <Link 198 167 className="hover:no-underline! font-bold" ··· 210 179 </div> 211 180 </div> 212 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> 213 273 </div> 214 274 ); 215 275 };
+8 -2
app/[leaflet_id]/publish/page.tsx
··· 76 76 // Get title and description from either source 77 77 let title = 78 78 data.leaflets_in_publications[0]?.title || 79 - data.leaflets_to_documents?.title || 79 + data.leaflets_to_documents[0]?.title || 80 80 decodeURIComponent((await props.searchParams).title || ""); 81 81 let description = 82 82 data.leaflets_in_publications[0]?.description || 83 - data.leaflets_to_documents?.description || 83 + data.leaflets_to_documents[0]?.description || 84 84 decodeURIComponent((await props.searchParams).description || ""); 85 85 86 86 let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); ··· 99 99 // If parsing fails, just use empty array 100 100 } 101 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 + 102 107 return ( 103 108 <ReplicacheProvider 104 109 rootEntity={rootEntity} ··· 116 121 record={publication?.record as PubLeafletPublication.Record | undefined} 117 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 118 123 entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 119 125 /> 120 126 </ReplicacheProvider> 121 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 5 import type { Env } from "./route"; 6 6 import { scanIndexLocal } from "src/replicache/utils"; 7 7 import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 9 import { applyUpdate, Doc } from "yjs"; 10 10 11 11 export const getFactsFromHomeLeaflets = makeRoute({
+6
app/api/rpc/[command]/pull.ts
··· 73 73 let publication_data = data.publications as { 74 74 description: string; 75 75 title: string; 76 + tags: string[]; 76 77 }[]; 77 78 let pub_patch = publication_data?.[0] 78 79 ? [ ··· 85 86 op: "put", 86 87 key: "publication_title", 87 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 88 94 }, 89 95 ] 90 96 : [];
+4
app/api/rpc/[command]/route.ts
··· 11 11 } from "./domain_routes"; 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 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"; 14 16 15 17 let supabase = createClient<Database>( 16 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 37 get_leaflet_subdomain_status, 36 38 get_leaflet_data, 37 39 get_publication_data, 40 + search_publication_names, 41 + search_publication_documents, 38 42 ]; 39 43 export async function POST( 40 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 211 212 212 /* END GLOBAL STYLING */ 213 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 214 219 button:hover { 215 220 cursor: pointer; 216 221 } ··· 289 294 .selected .selection-highlight { 290 295 background-color: Highlight; 291 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; 292 303 } 293 304 294 305 .ProseMirror:focus-within .selection-highlight { ··· 414 425 outline: none !important; 415 426 cursor: pointer; 416 427 background-color: transparent; 428 + display: flex; 429 + gap: 0.5rem; 417 430 418 431 :hover { 419 432 text-decoration: none !important;
+36 -206
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 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 26 183 27 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 28 pubName: string; 186 29 pub_uri: string; 187 30 base_url: string; ··· 208 51 } 209 52 return ( 210 53 <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 54 <div className="flex flex-row gap-2 place-self-center"> 217 55 <BlueskySubscribeButton 218 56 pub_uri={props.pub_uri} ··· 231 69 ); 232 70 }; 233 71 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 72 + export const ManageSubscription = (props: { 237 73 pub_uri: string; 238 74 subscribers: { identity: string }[]; 239 75 base_url: string; ··· 248 84 }); 249 85 }, null); 250 86 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 253 91 > 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> 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 267 94 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 - 95 + {!hasFeed && ( 280 96 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 283 98 target="_blank" 284 - aria-label="Subscribe to RSS" 99 + className=" place-self-center" 285 100 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 288 103 </ButtonPrimary> 289 104 </a> 105 + )} 290 106 291 - <hr className="border-border-light my-1" /> 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> 292 117 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> 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> 301 127 ); 302 128 }; 303 129 ··· 430 256 </Dialog.Root> 431 257 ); 432 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
+30
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 1 import { UnicodeString } from "@atproto/api"; 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 3 5 4 6 type Facet = PubLeafletRichtextFacet.Main; 5 7 export function BaseTextBlock(props: { ··· 21 23 let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 22 24 let isStrikethrough = segment.facet?.find( 23 25 PubLeafletRichtextFacet.isStrikethrough, 26 + ); 27 + let isDidMention = segment.facet?.find( 28 + PubLeafletRichtextFacet.isDidMention, 29 + ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 24 32 ); 25 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); ··· 47 55 <code key={counter} className={className} id={id?.id}> 48 56 {renderedText} 49 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>, 50 80 ); 51 81 } else if (link) { 52 82 children.push(
+6 -5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PollData } from "./fetchPollData"; 24 24 import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 25 26 26 27 export function CanvasPage({ 27 28 blocks, ··· 206 207 quotesCount: number | undefined; 207 208 commentsCount: number | undefined; 208 209 }) => { 210 + let isMobile = useIsMobile(); 209 211 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"> 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"> 211 213 <Interactions 212 214 quotesCount={props.quotesCount || 0} 213 215 commentsCount={props.commentsCount || 0} 214 - compact 215 216 showComments={props.preferences.showComments} 216 217 pageId={props.pageId} 217 218 /> ··· 219 220 <> 220 221 <Separator classname="h-5" /> 221 222 <Popover 222 - side="left" 223 - align="start" 224 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 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"}`} 225 226 trigger={<InfoSmall />} 226 227 > 227 228 <PostHeader
+223 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 8 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 9 import { EditorView } from "prosemirror-view"; 10 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 11 12 import { 12 13 MutableRefObject, 13 14 RefObject, 15 + useCallback, 14 16 useEffect, 15 17 useLayoutEffect, 16 18 useRef, ··· 36 38 import { CloseTiny } from "components/Icons/CloseTiny"; 37 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 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 + }; 39 83 40 84 export function CommentBox(props: { 41 85 doc_uri: string; ··· 50 94 commentBox: { quote }, 51 95 } = useInteractionState(props.doc_uri); 52 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; 53 112 54 - const handleSubmit = async () => { 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 () => { 55 159 if (loading || !view.current) return; 56 160 57 161 setLoading(true); ··· 114 218 "Mod-y": redo, 115 219 "Shift-Mod-z": redo, 116 220 "Ctrl-Enter": () => { 117 - handleSubmit(); 221 + handleSubmitRef.current(); 118 222 return true; 119 223 }, 120 224 "Meta-Enter": () => { 121 - handleSubmit(); 225 + handleSubmitRef.current(); 122 226 return true; 123 227 }, 124 228 }), ··· 128 232 shouldAutoLink: () => true, 129 233 defaultProtocol: "https", 130 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 + }), 131 245 history(), 132 246 ], 133 247 }), 134 248 ); 135 - let view = useRef<null | EditorView>(null); 136 249 useLayoutEffect(() => { 137 250 if (!mountRef.current) return; 138 251 view.current = new EditorView( ··· 187 300 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 301 if (!direct) return; 189 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 190 308 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); 309 + nodeAt1?.marks.find( 310 + (f) => f.type === multiBlockSchema.marks.link, 311 + ) || 312 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 313 if (mark) { 198 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; 199 352 } 200 353 }, 201 354 dispatchTransaction(tr) { ··· 236 389 <div className="w-full relative group"> 237 390 <pre 238 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 + }} 239 404 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 405 /> 241 406 <IOSBS view={view} /> 407 + <MentionAutocomplete 408 + open={mentionOpen} 409 + onOpenChange={handleMentionOpenChange} 410 + view={view} 411 + onSelect={handleMentionSelect} 412 + coords={mentionCoords} 413 + /> 242 414 </div> 243 415 <div className="flex justify-between pt-1"> 244 416 <div className="flex gap-1"> ··· 261 433 view={view} 262 434 /> 263 435 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 436 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 265 437 {loading ? <DotLoader /> : <ShareSmall />} 266 438 </ButtonPrimary> 267 439 </div> ··· 328 500 facets.push(facet); 329 501 } 330 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 + }); 331 543 332 544 fullText += text; 333 545 byteOffset += unicodeString.length;
+98 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 10 10 import { Json } from "supabase/database.types"; 11 11 import { 12 12 Notification, 13 + NotificationData, 13 14 pingIdentityToUpdateNotification, 14 15 } from "src/notifications"; 15 16 import { v7 } from "uuid"; ··· 84 85 parent_uri: args.comment.replyTo, 85 86 }, 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) { 87 100 // SOMEDAY: move this out the action with inngest or workflows 88 101 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 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 + ); 90 108 } 91 109 92 110 return { ··· 95 113 uri: uri.toString(), 96 114 }; 97 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 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 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"; 12 15 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 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"; 15 22 16 23 export type InteractionState = { 17 24 drawerOpen: undefined | boolean; ··· 99 106 export const Interactions = (props: { 100 107 quotesCount: number; 101 108 commentsCount: number; 102 - compact?: boolean; 103 109 className?: string; 104 110 showComments?: boolean; 105 111 pageId?: string; 106 112 }) => { 107 113 const data = useContext(PostPageContext); 108 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 109 116 if (!document_uri) 110 117 throw new Error("document_uri not available in PostPageContext"); 111 118 ··· 117 124 } 118 125 }; 119 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 120 130 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> 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 + )} 142 149 {props.showComments === false ? null : ( 143 150 <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"}`} 151 + className="flex gap-2 items-center w-fit" 145 152 onClick={() => { 146 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 156 }} 150 157 aria-label="Post comments" 151 158 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 159 + <CommentTiny aria-hidden /> {props.commentsCount} 158 160 </button> 159 161 )} 160 162 </div> 161 163 ); 162 164 }; 163 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 + }; 164 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 323 if (!document) return; 166 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 357 ).length; 200 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 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 12 import { EditTiny } from "components/Icons/EditTiny"; 13 13 import { 14 + ExpandedInteractions, 14 15 getCommentCount, 15 16 getQuoteCount, 16 17 Interactions, ··· 47 48 fullPageScroll, 48 49 hasPageBackground, 49 50 } = props; 50 - let { identity } = useIdentityData(); 51 51 let drawer = useDrawerOpen(document_uri); 52 52 53 53 if (!document) return null; ··· 84 84 did={did} 85 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 86 /> 87 - <Interactions 87 + 88 + <ExpandedInteractions 88 89 pageId={pageId} 89 90 showComments={preferences.showComments} 90 91 commentsCount={getCommentCount(document, pageId) || 0} 91 92 quotesCount={getQuoteCount(document, pageId) || 0} 92 93 /> 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 - )} 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 131 95 </PageWrapper> 132 96 </> 133 97 );
+11 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 59 return ( 60 60 <div 61 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}`} 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 63 > 64 64 {blocks.map((b, index) => { 65 65 return ( ··· 293 293 } 294 294 case PubLeafletBlocksImage.isMain(b.block): { 295 295 return ( 296 - <div className={`relative flex ${alignment}`} {...blockProps}> 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 297 300 <img 298 301 alt={b.block.alt} 299 302 height={b.block.aspectRatio?.height} ··· 321 324 return ( 322 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. 323 326 <blockquote 324 - className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 327 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 325 328 {...blockProps} 326 329 > 327 330 <TextBlock ··· 336 339 } 337 340 case PubLeafletBlocksText.isMain(b.block): 338 341 return ( 339 - <p className={` ${className}`} {...blockProps}> 342 + <p className={`textBlock ${className}`} {...blockProps}> 340 343 <TextBlock 341 344 facets={b.block.facets} 342 345 plaintext={b.block.plaintext} ··· 349 352 case PubLeafletBlocksHeader.isMain(b.block): { 350 353 if (b.block.level === 1) 351 354 return ( 352 - <h2 className={`${className}`} {...blockProps}> 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 353 356 <TextBlock 354 357 {...b.block} 355 358 index={index} ··· 360 363 ); 361 364 if (b.block.level === 2) 362 365 return ( 363 - <h3 className={`${className}`} {...blockProps}> 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 364 367 <TextBlock 365 368 {...b.block} 366 369 index={index} ··· 371 374 ); 372 375 if (b.block.level === 3) 373 376 return ( 374 - <h4 className={`${className}`} {...blockProps}> 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 375 378 <TextBlock 376 379 {...b.block} 377 380 index={index} ··· 383 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 384 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 385 388 return ( 386 - <h6 className={`${className}`} {...blockProps}> 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 387 390 <TextBlock 388 391 {...b.block} 389 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 16 import { EditTiny } from "components/Icons/EditTiny"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 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"; 19 21 20 22 export function PostHeader(props: { 21 23 data: PostPageData; ··· 40 42 41 43 if (!document?.data) return; 42 44 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"> 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 49 48 {pub && ( 50 49 <SpeedyLink 51 50 className="font-bold hover:no-underline text-accent-contrast" ··· 65 64 <EditTiny className="shrink-0" /> 66 65 </a> 67 66 )} 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 - |{" "} 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 91 <Interactions 92 92 showComments={props.preferences.showComments} 93 - compact 94 93 quotesCount={getQuoteCount(document) || 0} 95 94 commentsCount={getCommentCount(document) || 0} 96 95 /> 97 - </div> 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} 98 128 </div> 99 129 </div> 100 130 ); 101 - } 131 + };
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 25 25 26 26 return { 27 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 + }, 28 36 other: { 29 37 rel: "alternate", 30 38 url: document.uri,
+20 -33
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 4 4 import React from "react"; 5 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 6 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 - import { EmptyState } from "components/EmptyState"; 8 7 9 8 export function DraftList(props: { 10 9 searchValue: string; ··· 13 12 let { data: pub_data } = usePublicationData(); 14 13 if (!pub_data?.publication) return null; 15 14 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 15 return ( 48 16 <div className="flex flex-col gap-4"> 49 17 <NewDraftSecondaryButton ··· 56 24 showPreview={false} 57 25 defaultDisplay="list" 58 26 cardBorderHidden={!props.showPageBackground} 59 - leaflets={filteredLeaflets} 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 + })} 60 47 initialFacts={pub_data.leaflet_data.facts || {}} 61 48 titles={{ 62 49 ...leaflets_in_publications.reduce(
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 32 32 <ButtonSecondary 33 33 fullWidth={props.fullWidth} 34 34 id="new-leaflet-button" 35 - className="mx-auto" 36 35 onClick={async () => { 37 36 let newLeaflet = await createPublicationDraft(props.publication); 38 37 router.push(`/${newLeaflet}`);
+25 -31
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 import { EditTiny } from "components/Icons/EditTiny"; 5 5 6 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 20 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 23 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 23 - import { EmptyState } from "components/EmptyState"; 24 24 25 25 export function PublishedPostsList(props: { 26 26 searchValue: string; ··· 29 29 let { data } = usePublicationData(); 30 30 let params = useParams(); 31 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 32 34 if (!publication) return null; 33 35 if (publication.documents_in_publications.length === 0) 34 - return <EmptyState>Nothing's been published yet...</EmptyState>; 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 + ); 35 41 return ( 36 42 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 37 43 {publication.documents_in_publications ··· 52 58 (l) => doc.documents && l.doc === doc.documents.uri, 53 59 ); 54 60 let uri = new AtUri(doc.documents.uri); 55 - let record = doc.documents.data as PubLeafletDocument.Record; 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 56 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 57 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 58 65 59 66 let postLink = data?.publication 60 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 78 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 79 86 > 80 87 <h3 className="text-primary grow leading-snug"> 81 - {record.title} 88 + {postRecord.title} 82 89 </h3> 83 90 </a> 84 91 <div className="flex justify-start align-top flex-row gap-1"> ··· 107 114 : null, 108 115 }, 109 116 ], 110 - leaflets_to_documents: null, 117 + leaflets_to_documents: [], 111 118 blocked_by_admin: null, 112 119 custom_domain_routes: [], 113 120 }} ··· 119 126 </div> 120 127 </div> 121 128 122 - {record.description ? ( 129 + {postRecord.description ? ( 123 130 <p className="italic text-secondary"> 124 - {record.description} 131 + {postRecord.description} 125 132 </p> 126 133 ) : null} 127 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 128 - {record.publishedAt ? ( 129 - <PublishedDate dateString={record.publishedAt} /> 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} /> 130 137 ) : 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 - )} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 151 145 </div> 152 146 </div> 153 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 47 title: pubRecord?.name || "Untitled Publication", 48 48 description: pubRecord?.description || "", 49 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 + }, 50 58 other: { 51 59 rel: "alternate", 52 60 url: publication.uri,
+9 -17
app/lish/[did]/[publication]/page.tsx
··· 14 14 import { SpeedyLink } from "components/SpeedyLink"; 15 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 17 18 import { LocalizedDate } from "./LocalizedDate"; 18 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 19 20 ··· 134 135 record?.preferences?.showComments === false 135 136 ? 0 136 137 : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 137 139 138 140 return ( 139 141 <React.Fragment key={doc.documents?.uri}> ··· 162 164 )}{" "} 163 165 </p> 164 166 {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 - )} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 182 174 </div> 183 175 </div> 184 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 3 import { useContext, useEffect } from "react"; 4 4 import { SidebarContext } from "./Sidebar"; 5 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 7 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9 ··· 11 11 _props: ButtonProps & { 12 12 id?: string; 13 13 icon: React.ReactNode; 14 - label?: React.ReactNode; 14 + label: React.ReactNode; 15 15 primary?: boolean; 16 16 secondary?: boolean; 17 17 nav?: boolean; ··· 69 69 `} 70 70 > 71 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 - )} 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> 84 82 </button> 85 83 ); 86 84 };
+4 -8
components/ActionBar/Navigation.tsx
··· 24 24 | "pub" 25 25 | "discover" 26 26 | "notifications" 27 - | "looseleafs"; 27 + | "looseleafs" 28 + | "tag"; 28 29 29 30 export const DesktopNavigation = (props: { 30 31 currentPage: navPages; ··· 126 127 }; 127 128 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 130 if (!props.subs) return; 132 131 return ( 133 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 133 <ActionButton 135 134 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 135 + icon={<ReaderUnreadSmall />} 137 136 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 142 138 /> 143 139 </SpeedyLink> 144 140 );
+2 -3
components/ActionBar/Publications.tsx
··· 23 23 currentPubUri: string | undefined; 24 24 }) => { 25 25 let { identity } = useIdentityData(); 26 - let hasLooseleafs = identity?.permission_token_on_homepage.find( 26 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 27 27 (f) => 28 28 f.permission_tokens.leaflets_to_documents && 29 - f.permission_tokens.leaflets_to_documents.document, 29 + f.permission_tokens.leaflets_to_documents[0]?.document, 30 30 ); 31 - console.log(hasLooseleafs); 32 31 33 32 // don't show pub list button if not logged in or no pub list 34 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 37 const clearCommandSearchText = () => { 38 38 if (!props.entityID) return; 39 39 const entityID = props.entityID; 40 - 40 + 41 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 42 if (!existingState) return; 43 43 ··· 69 69 setHighlighted(commandResults[0].name); 70 70 } 71 71 }, [commandResults, setHighlighted, highlighted]); 72 + 72 73 useEffect(() => { 73 74 let listener = async (e: KeyboardEvent) => { 74 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 119 return; 119 120 } 120 121 }; 122 + 121 123 window.addEventListener("keydown", listener); 122 124 123 125 return () => window.removeEventListener("keydown", listener); ··· 200 202 201 203 return ( 202 204 <button 203 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 204 206 onMouseOver={() => { 205 207 props.setHighlighted(props.name); 206 208 }}
+3 -3
components/Blocks/BlockCommands.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 4 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { v7 } from "uuid"; 7 7 import { Replicache } from "replicache"; 8 8 import { useEditorStates } from "src/state/useEditorState"; 9 9 import { elementId } from "src/utils/elementId"; 10 10 import { UndoManager } from "src/undoManager"; 11 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 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"; 1 + import { Fact, useReplicache } from "src/replicache"; 12 2 import { ButtonPrimary } from "components/Buttons"; 13 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 14 5 15 6 export const AreYouSure = (props: { 16 7 entityID: string[] | string; ··· 82 73 ); 83 74 }; 84 75 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 - }
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 12 13 import { isUrl } from "src/utils/isURL"; 13 14 import { elementId } from "src/utils/elementId"; 14 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 12 + import { focusPage } from "src/utils/focusPage"; 13 13 import { v7 } from "uuid"; 14 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { useEntity, useReplicache } from "src/replicache"; 7 7 import { useUIState } from "src/useUIState"; 8 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 1 import { useUIState } from "src/useUIState"; 2 2 import { BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 - import { focusElement, AsyncValueInput } from "components/Input"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 import { useEntitySetContext } from "components/EntitySetProvider"; 6 7 import { useEntity, useReplicache } from "src/replicache"; 7 8 import { v7 } from "uuid";
+31 -35
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 3 import { CSSProperties, Fragment } from "react"; 4 4 import { theme } from "tailwind.config"; 5 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"; 6 9 7 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 11 export function RenderYJSFragment({ ··· 64 67 return <br key={index} />; 65 68 } 66 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 + 67 98 return null; 68 99 }) 69 100 )} ··· 101 132 } 102 133 }; 103 134 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 135 function attributesToStyle(d: Delta) { 118 136 let props = { 119 137 style: {}, ··· 144 162 return props; 145 163 } 146 164 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 - }
+109 -14
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 3 import { useReplicache, useEntity } from "src/replicache"; 4 4 import { isVisible } from "src/utils/isVisible"; 5 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 6 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 8 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8 9 import { BlockProps } from "../Block"; ··· 23 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 25 import { DotLoader } from "components/utils/DotLoader"; 25 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"; 26 31 27 32 const HeadingStyle = { 28 33 1: "text-xl font-bold", ··· 183 188 let editorState = useEditorStates( 184 189 (s) => s.editorStates[props.entityID], 185 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 186 199 187 200 let { mountRef, actionTimeout } = useMountProsemirror({ 188 201 props, 202 + openMentionAutocomplete, 189 203 }); 190 204 191 205 return ( ··· 199 213 ? "blockquote pt-3" 200 214 : "blockquote" 201 215 : "" 202 - } 203 - 204 - `} 216 + }`} 205 217 > 206 218 <pre 207 219 data-entityid={props.entityID} ··· 224 236 } 225 237 }} 226 238 onFocus={() => { 239 + handleMentionOpenChange(false); 227 240 setTimeout(() => { 228 241 useUIState.getState().setSelectedBlock(props); 229 242 useUIState.setState(() => ({ ··· 249 262 ${props.className}`} 250 263 ref={mountRef} 251 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 252 274 {editorState?.doc.textContent.length === 0 && 253 275 props.previousBlock === null && 254 276 props.nextBlock === null ? ( ··· 439 461 ); 440 462 }; 441 463 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 }; 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 + }; 452 547 };
+20
components/Blocks/TextBlock/inputRules.ts
··· 15 15 export const inputrules = ( 16 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 18 19 ) => 19 20 inputRules({ 20 21 //Strikethrough ··· 189 190 data: { type: "number", value: headingLevel }, 190 191 }); 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 192 212 }), 193 213 ], 194 214 });
+5 -8
components/Blocks/TextBlock/keymap.ts
··· 17 17 import { schema } from "./schema"; 18 18 import { useUIState } from "src/useUIState"; 19 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 20 + import { focusPage } from "src/utils/focusPage"; 21 21 import { v7 } from "uuid"; 22 22 import { scanIndex } from "src/replicache/utils"; 23 23 import { indent, outdent } from "src/utils/list-operations"; 24 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 - 28 27 type PropsRef = RefObject< 29 28 BlockProps & { 30 29 entity_set: { set: string }; ··· 35 34 propsRef: PropsRef, 36 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 36 um: UndoManager, 38 - multiLine?: boolean, 37 + openMentionAutocomplete: () => void, 39 38 ) => 40 39 ({ 41 40 "Meta-b": toggleMark(schema.marks.strong), ··· 138 137 ), 139 138 "Shift-Backspace": backspace(propsRef, repRef), 140 139 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 - ); 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 146 143 }, 147 144 "Shift-Enter": (state, dispatch, view) => { 148 145 // Insert a hard break
+48 -12
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 23 import { useHandlePaste } from "./useHandlePaste"; 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 26 27 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 28 35 let { entityID, parent } = props; 29 36 let rep = useReplicache(); 30 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 51 useLayoutEffect(() => { 45 52 if (!mountRef.current) return; 46 53 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 48 60 const editor = EditorState.create({ 49 61 schema: schema, 50 62 plugins: [ 51 63 ySyncPlugin(value), 52 64 keymap(km), 53 - inputrules(propsRef, repRef), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 54 66 keymap(baseKeymap), 55 67 highlightSelectionPlugin, 56 68 autolink({ ··· 69 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 82 if (!direct) return; 71 83 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"); 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; 81 117 } 82 118 }, 83 119 dispatchTransaction,
+100 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 2 3 import { marks } from "prosemirror-schema-basic"; 3 4 import { theme } from "tailwind.config"; 4 5 ··· 122 123 parseDOM: [{ tag: "br" }], 123 124 toDOM: () => ["br"] as const, 124 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, 125 224 }, 126 225 }; 127 226 export const schema = new Schema(baseSchema);
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 16 import { entities } from "drizzle/schema"; 17 17 import { scanIndex } from "src/replicache/utils"; 18 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 2 import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 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 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 6 7 export const Input = ( 7 8 props: { ··· 56 57 }} 57 58 /> 58 59 ); 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 60 }; 94 61 95 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 3 import { theme } from "tailwind.config"; 4 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 7 import { useState } from "react"; 8 8 9 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 372 ); 373 373 } 374 374 375 - const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 375 + const FilterOptions = (props: { 376 + hasPubs: boolean; 377 + hasArchived: boolean; 378 + }) => { 376 379 let { filter } = useDashboardState(); 377 380 let setState = useSetDashboardState(); 378 381 let filterCount = Object.values(filter).filter(Boolean).length; ··· 469 472 type="text" 470 473 id="pubName" 471 474 size={1} 472 - placeholder="searchโ€ฆ" 475 + placeholder="search..." 473 476 value={props.searchValue} 474 477 onChange={(e) => { 475 478 props.setSearchValue(e.currentTarget.value);
+4 -8
components/PageSWRDataProvider.tsx
··· 90 90 const publishedInPublication = data.leaflets_in_publications?.find( 91 91 (l) => l.doc, 92 92 ); 93 - const publishedStandalone = 94 - data.leaflets_to_documents && data.leaflets_to_documents.documents 95 - ? data.leaflets_to_documents 96 - : null; 93 + const publishedStandalone = data.leaflets_to_documents?.find( 94 + (l) => !!l.documents, 95 + ); 97 96 98 97 const documentUri = 99 98 publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 100 99 101 100 // Compute the full post URL for sharing 102 101 let postShareLink: string | undefined; 103 - if ( 104 - publishedInPublication?.publications && 105 - publishedInPublication.documents 106 - ) { 102 + if (publishedInPublication?.publications && publishedInPublication.documents) { 107 103 // Published in a publication - use publication URL + document rkey 108 104 const docUri = new AtUri(publishedInPublication.documents.uri); 109 105 postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+1 -1
components/Pages/Page.tsx
··· 12 12 import { Blocks } from "components/Blocks"; 13 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 15 + import { focusPage } from "src/utils/focusPage"; 16 16 import { PageOptions } from "./PageOptions"; 17 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+156 -84
components/Pages/PublicationMetadata.tsx
··· 1 1 import Link from "next/link"; 2 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 3 + import { useRef, useState } from "react"; 4 4 import { useReplicache } from "src/replicache"; 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 9 import { 10 10 getBasePublicationURL, 11 11 getPublicationURL, ··· 13 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 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"; 16 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 17 23 export const PublicationMetadata = () => { 18 24 let { rep } = useReplicache(); 19 25 let { data: pub } = useLeafletPublicationData(); ··· 23 29 tx.get<string>("publication_description"), 24 30 ); 25 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 26 35 let publishedAt = record?.publishedAt; 27 36 28 37 if (!pub) return null; ··· 33 42 if (typeof description !== "string") { 34 43 description = pub?.description || ""; 35 44 } 45 + let tags = true; 46 + 36 47 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> 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> 94 66 </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </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 + /> 99 136 ); 100 137 }; 101 138 ··· 178 215 if (!pub) return null; 179 216 180 217 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> 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 + }; 185 236 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> 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 194 241 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> 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"} 200 272 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 205 277 ); 206 278 };
+2 -75
components/Pages/index.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { useSearchParams } from "next/navigation"; 6 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 7 + import { useEntity } from "src/replicache"; 9 8 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 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 55 ); 63 56 } 64 57 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 = () => { 58 + const blurPage = () => { 132 59 useUIState.setState(() => ({ 133 60 focusedEntity: null, 134 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 2 import { useMemo, useState } from "react"; 3 3 import { parseColor } from "react-aria-components"; 4 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 5 + import { getColorContrast } from "./themeUtils"; 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 84 84 <div 85 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 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 88 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 92 }}
+1 -40
components/ThemeManager/ThemeProvider.tsx
··· 5 5 CSSProperties, 6 6 useContext, 7 7 useEffect, 8 - useMemo, 9 - useState, 10 8 } from "react"; 11 9 import { 12 10 colorToString, ··· 14 12 useColorAttributeNullable, 15 13 } from "./useColorAttribute"; 16 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 15 19 16 import { useEntity } from "src/replicache"; 20 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 20 PublicationThemeProvider, 24 21 } from "./PublicationThemeProvider"; 25 22 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 - }; 23 + import { getColorContrast } from "./themeUtils"; 54 24 55 25 // define a function to set an Aria Color to a CSS Variable in RGB 56 26 function setCSSVariableToColor( ··· 368 338 ); 369 339 }; 370 340 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 - }
+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 2 import { Color, parseColor } from "react-aria-components"; 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 6 7 7 export function useColorAttribute( 8 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 2 import { ToolbarButton } from "."; 3 3 import { Separator, ShortcutKey } from "components/Layout"; 4 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 5 import { useUIState } from "src/useUIState"; 7 6 import { LockBlockButton } from "./LockBlockButton"; 8 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 11 12 12 export const BlockToolbar = (props: { 13 13 setToolbarState: ( ··· 66 66 67 67 const MoveBlockButtons = () => { 68 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 69 return ( 81 70 <> 82 71 <ToolbarButton 83 72 hiddenOnCanvas 84 73 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 86 76 if (sortedBlocks.length > 1) return; 87 77 let block = sortedBlocks[0]; 88 78 let previousBlock = ··· 139 129 <ToolbarButton 140 130 hiddenOnCanvas 141 131 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 143 134 if (sortedBlocks.length > 1) return; 144 135 let block = sortedBlocks[0]; 145 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 8 import { LockBlockButton } from "./LockBlockButton"; 9 9 import { Props } from "components/Icons/Props"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 13 import { TextToolbar } from "./TextToolbar"; 14 14 import { BlockToolbar } from "./BlockToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 17 18 import { TooltipButton } from "components/Buttons"; 18 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 8 import { useEntity, useReplicache } from "src/replicache"; 9 9 import * as Y from "yjs"; 10 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 13 import { focusBlock } from "src/utils/focusBlock"; 14 14 import { useIsMobile } from "src/hooks/isMobile";
+31
lexicons/api/lexicons.ts
··· 1440 1440 type: 'ref', 1441 1441 ref: 'lex:pub.leaflet.publication#theme', 1442 1442 }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 + }, 1443 1450 pages: { 1444 1451 type: 'array', 1445 1452 items: { ··· 1865 1872 type: 'union', 1866 1873 refs: [ 1867 1874 'lex:pub.leaflet.richtext.facet#link', 1875 + 'lex:pub.leaflet.richtext.facet#didMention', 1876 + 'lex:pub.leaflet.richtext.facet#atMention', 1868 1877 'lex:pub.leaflet.richtext.facet#code', 1869 1878 'lex:pub.leaflet.richtext.facet#highlight', 1870 1879 'lex:pub.leaflet.richtext.facet#underline', ··· 1901 1910 properties: { 1902 1911 uri: { 1903 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', 1904 1935 }, 1905 1936 }, 1906 1937 },
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 23 publication?: string 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 + tags?: string[] 26 27 pages: ( 27 28 | $Typed<PubLeafletPagesLinearDocument.Main> 28 29 | $Typed<PubLeafletPagesCanvas.Main>
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 20 index: ByteSlice 21 21 features: ( 22 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 23 25 | $Typed<Code> 24 26 | $Typed<Highlight> 25 27 | $Typed<Underline> ··· 72 74 73 75 export function validateLink<V>(v: V) { 74 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) 75 109 } 76 110 77 111 /** Facet feature for inline code. */
+7
lexicons/pub/leaflet/document.json
··· 46 46 "type": "ref", 47 47 "ref": "pub.leaflet.publication#theme" 48 48 }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 + }, 49 56 "pages": { 50 57 "type": "array", 51 58 "items": {
+28
lexicons/pub/leaflet/richtext/facet.json
··· 20 20 "type": "union", 21 21 "refs": [ 22 22 "#link", 23 + "#didMention", 24 + "#atMention", 23 25 "#code", 24 26 "#highlight", 25 27 "#underline", ··· 59 61 "properties": { 60 62 "uri": { 61 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" 62 90 } 63 91 } 64 92 },
+1
lexicons/src/document.ts
··· 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 26 27 pages: { 27 28 type: "array", 28 29 items: {
+12
lexicons/src/facet.ts
··· 9 9 uri: { type: "string" }, 10 10 }, 11 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 + }, 12 24 code: { 13 25 type: "object", 14 26 description: "Facet feature for inline code.",
+68 -49
package-lock.json
··· 48 48 "inngest": "^3.40.1", 49 49 "ioredis": "^5.6.1", 50 50 "katex": "^0.16.22", 51 + "l": "^0.6.0", 51 52 "linkifyjs": "^4.2.0", 52 53 "luxon": "^3.7.2", 53 54 "multiformats": "^13.3.2", 54 - "next": "16.0.3", 55 + "next": "^16.0.7", 55 56 "pg": "^8.16.3", 56 57 "prosemirror-commands": "^1.5.2", 57 58 "prosemirror-inputrules": "^1.4.0", ··· 59 60 "prosemirror-model": "^1.21.0", 60 61 "prosemirror-schema-basic": "^1.2.2", 61 62 "prosemirror-state": "^1.4.3", 62 - "react": "19.2.0", 63 + "react": "19.2.1", 63 64 "react-aria-components": "^1.8.0", 64 65 "react-day-picker": "^9.3.0", 65 - "react-dom": "19.2.0", 66 + "react-dom": "19.2.1", 66 67 "react-use-measure": "^2.1.1", 67 68 "redlock": "^5.0.0-beta.2", 68 69 "rehype-parse": "^9.0.0", ··· 2734 2735 } 2735 2736 }, 2736 2737 "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==" 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" 2740 2742 }, 2741 2743 "node_modules/@next/eslint-plugin-next": { 2742 2744 "version": "16.0.3", ··· 2804 2806 } 2805 2807 }, 2806 2808 "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==", 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==", 2810 2812 "cpu": [ 2811 2813 "arm64" 2812 2814 ], 2815 + "license": "MIT", 2813 2816 "optional": true, 2814 2817 "os": [ 2815 2818 "darwin" ··· 2819 2822 } 2820 2823 }, 2821 2824 "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 + "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==", 2825 2828 "cpu": [ 2826 2829 "x64" 2827 2830 ], 2831 + "license": "MIT", 2828 2832 "optional": true, 2829 2833 "os": [ 2830 2834 "darwin" ··· 2834 2838 } 2835 2839 }, 2836 2840 "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==", 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==", 2840 2844 "cpu": [ 2841 2845 "arm64" 2842 2846 ], 2847 + "license": "MIT", 2843 2848 "optional": true, 2844 2849 "os": [ 2845 2850 "linux" ··· 2849 2854 } 2850 2855 }, 2851 2856 "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==", 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==", 2855 2860 "cpu": [ 2856 2861 "arm64" 2857 2862 ], 2863 + "license": "MIT", 2858 2864 "optional": true, 2859 2865 "os": [ 2860 2866 "linux" ··· 2864 2870 } 2865 2871 }, 2866 2872 "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==", 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==", 2870 2876 "cpu": [ 2871 2877 "x64" 2872 2878 ], 2879 + "license": "MIT", 2873 2880 "optional": true, 2874 2881 "os": [ 2875 2882 "linux" ··· 2879 2886 } 2880 2887 }, 2881 2888 "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==", 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==", 2885 2892 "cpu": [ 2886 2893 "x64" 2887 2894 ], 2895 + "license": "MIT", 2888 2896 "optional": true, 2889 2897 "os": [ 2890 2898 "linux" ··· 2894 2902 } 2895 2903 }, 2896 2904 "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==", 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==", 2900 2908 "cpu": [ 2901 2909 "arm64" 2902 2910 ], 2911 + "license": "MIT", 2903 2912 "optional": true, 2904 2913 "os": [ 2905 2914 "win32" ··· 2909 2918 } 2910 2919 }, 2911 2920 "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==", 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==", 2915 2924 "cpu": [ 2916 2925 "x64" 2917 2926 ], 2927 + "license": "MIT", 2918 2928 "optional": true, 2919 2929 "os": [ 2920 2930 "win32" ··· 13360 13370 "json-buffer": "3.0.1" 13361 13371 } 13362 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 + }, 13363 13379 "node_modules/language-subtag-registry": { 13364 13380 "version": "0.3.23", 13365 13381 "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", ··· 15108 15124 } 15109 15125 }, 15110 15126 "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==", 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", 15114 15131 "dependencies": { 15115 - "@next/env": "16.0.3", 15132 + "@next/env": "16.0.7", 15116 15133 "@swc/helpers": "0.5.15", 15117 15134 "caniuse-lite": "^1.0.30001579", 15118 15135 "postcss": "8.4.31", ··· 15125 15142 "node": ">=20.9.0" 15126 15143 }, 15127 15144 "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", 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", 15136 15153 "sharp": "^0.34.4" 15137 15154 }, 15138 15155 "peerDependencies": { ··· 16321 16338 } 16322 16339 }, 16323 16340 "node_modules/react": { 16324 - "version": "19.2.0", 16325 - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", 16326 - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", 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", 16327 16345 "engines": { 16328 16346 "node": ">=0.10.0" 16329 16347 } ··· 16442 16460 } 16443 16461 }, 16444 16462 "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==", 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", 16448 16467 "dependencies": { 16449 16468 "scheduler": "^0.27.0" 16450 16469 }, 16451 16470 "peerDependencies": { 16452 - "react": "^19.2.0" 16471 + "react": "^19.2.1" 16453 16472 } 16454 16473 }, 16455 16474 "node_modules/react-is": {
+4 -3
package.json
··· 58 58 "inngest": "^3.40.1", 59 59 "ioredis": "^5.6.1", 60 60 "katex": "^0.16.22", 61 + "l": "^0.6.0", 61 62 "linkifyjs": "^4.2.0", 62 63 "luxon": "^3.7.2", 63 64 "multiformats": "^13.3.2", 64 - "next": "16.0.3", 65 + "next": "^16.0.7", 65 66 "pg": "^8.16.3", 66 67 "prosemirror-commands": "^1.5.2", 67 68 "prosemirror-inputrules": "^1.4.0", ··· 69 70 "prosemirror-model": "^1.21.0", 70 71 "prosemirror-schema-basic": "^1.2.2", 71 72 "prosemirror-state": "^1.4.3", 72 - "react": "19.2.0", 73 + "react": "19.2.1", 73 74 "react-aria-components": "^1.8.0", 74 75 "react-day-picker": "^9.3.0", 75 - "react-dom": "19.2.0", 76 + "react-dom": "19.2.1", 76 77 "react-use-measure": "^2.1.1", 77 78 "redlock": "^5.0.0-beta.2", 78 79 "rehype-parse": "^9.0.0",
+3 -1
src/hooks/useLocalizedDate.ts
··· 28 28 29 29 // On initial page load, use header timezone. After hydration, use system timezone 30 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 31 + ? timezone || "UTC" 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 33 35 34 36 // Apply timezone if available 35 37 if (effectiveTimezone) {
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 6 useEffect(() => { 7 7 if (!ref.current || !key) return; 8 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 12 13 13 14 const listener = () => { 14 15 if (!ref.current?.scrollTop) return;
+254 -37
src/notifications.ts
··· 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 7 6 8 type NotificationRow = Tables<"notifications">; 7 9 ··· 12 14 export type NotificationData = 13 15 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 16 | { type: "subscribe"; subscription_uri: string } 15 - | { type: "quote"; bsky_post_uri: string; document_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 }; 16 24 17 25 export type HydratedNotification = 18 26 | HydratedCommentNotification 19 27 | HydratedSubscribeNotification 20 - | HydratedQuoteNotification; 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 21 31 export async function hydrateNotifications( 22 32 notifications: NotificationRow[], 23 33 ): Promise<Array<HydratedNotification>> { 24 34 // Call all hydrators in parallel 25 - const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([ 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 26 36 hydrateCommentNotifications(notifications), 27 37 hydrateSubscribeNotifications(notifications), 28 38 hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 29 41 ]); 30 42 31 43 // Combine all hydrated notifications 32 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications]; 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 33 45 34 46 // Sort by created_at to maintain order 35 47 allHydrated.sort( ··· 73 85 ) 74 86 .in("uri", commentUris); 75 87 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 - })); 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); 89 105 } 90 106 91 107 export type HydratedSubscribeNotification = Awaited< ··· 113 129 .select("*, identities(bsky_profiles(*)), publications(*)") 114 130 .in("uri", subscriptionUris); 115 131 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 - })); 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); 126 146 } 127 147 128 148 export type HydratedQuoteNotification = Awaited< ··· 153 173 .select("*, documents_in_publications(publications(*))") 154 174 .in("uri", documentUris); 155 175 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 - })); 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); 166 383 } 167 384 168 385 export async function pingIdentityToUpdateNotification(did: string) {
+34 -8
src/replicache/mutations.ts
··· 609 609 }; 610 610 611 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 614 615 }> = async (args, ctx) => { 615 616 await ctx.runOnServer(async (serverCtx) => { 616 617 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); 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 + } 621 644 }); 622 645 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 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); 625 651 }); 626 652 }; 627 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 17 hostname = "leaflet.pub"; 18 18 } 19 19 let full_path = `${protocol}://${hostname}${path}`; 20 - return getWebpageImage(full_path, options); 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 21 24 } 22 25 23 26 export async function getWebpageImage( 24 27 url: string, 25 28 options?: { 29 + setJavaScriptEnabled?: boolean; 26 30 width?: number; 27 31 height?: number; 28 32 deviceScaleFactor?: number; ··· 39 43 }, 40 44 body: JSON.stringify({ 41 45 url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 42 47 scrollPage: true, 43 48 addStyleTag: [ 44 49 {
+7 -31
src/utils/getPublicationMetadataFromLeafletData.ts
··· 32 32 (p) => p.leaflets_in_publications?.length, 33 33 )?.leaflets_in_publications?.[0]; 34 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 - ) { 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) { 45 42 // Transform standalone document data to match the expected format 46 43 pubData = { 47 44 ...standaloneDoc, 48 45 publications: null, // No publication for standalone docs 49 46 doc: standaloneDoc.document, 50 - leaflet: data.id, 51 47 }; 52 48 } 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 49 return pubData; 74 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 631 Row: { 632 632 created_at: string 633 633 description: string 634 - document: string | null 634 + document: string 635 635 leaflet: string 636 636 title: string 637 637 } 638 638 Insert: { 639 639 created_at?: string 640 640 description?: string 641 - document?: string | null 641 + document: string 642 642 leaflet: string 643 643 title?: string 644 644 } 645 645 Update: { 646 646 created_at?: string 647 647 description?: string 648 - document?: string | null 648 + document?: string 649 649 leaflet?: string 650 650 title?: string 651 651 } ··· 660 660 { 661 661 foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 662 columns: ["leaflet"] 663 - isOneToOne: true 663 + isOneToOne: false 664 664 referencedRelation: "permission_tokens" 665 665 referencedColumns: ["id"] 666 666 }, ··· 1157 1157 client_group_id: string 1158 1158 } 1159 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 + }[] 1160 1169 } 1161 1170 } 1162 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 + ;