a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+12492 -5146
actions
app
(home-pages)
[leaflet_id]
api
atproto_images
bsky
quotes
thread
inngest
functions
link_previews
oauth
[route]
pub_icon
rpc
unstable_validate
lish
p
[didOrHandle]
appview
components
ActionBar
Blocks
Icons
PageLayouts
Pages
Popover
SelectionManager
ThemeManager
Toolbar
utils
lexicons
src
supabase
-51
actions/createIdentity.ts
··· 1 - import { 2 - entities, 3 - permission_tokens, 4 - permission_token_rights, 5 - entity_sets, 6 - identities, 7 - } from "drizzle/schema"; 8 - import { v7 } from "uuid"; 9 - import { PgTransaction } from "drizzle-orm/pg-core"; 10 - import { NodePgDatabase } from "drizzle-orm/node-postgres"; 11 - import { Json } from "supabase/database.types"; 12 - 13 - export async function createIdentity( 14 - db: NodePgDatabase, 15 - data?: { email?: string; atp_did?: string }, 16 - ) { 17 - return db.transaction(async (tx) => { 18 - // Create a new entity set 19 - let [entity_set] = await tx.insert(entity_sets).values({}).returning(); 20 - // Create a root-entity 21 - let [entity] = await tx 22 - .insert(entities) 23 - // And add it to that permission set 24 - .values({ set: entity_set.id, id: v7() }) 25 - .returning(); 26 - //Create a new permission token 27 - let [permissionToken] = await tx 28 - .insert(permission_tokens) 29 - .values({ root_entity: entity.id }) 30 - .returning(); 31 - //and give it all the permission on that entity set 32 - let [rights] = await tx 33 - .insert(permission_token_rights) 34 - .values({ 35 - token: permissionToken.id, 36 - entity_set: entity_set.id, 37 - read: true, 38 - write: true, 39 - create_token: true, 40 - change_entity_set: true, 41 - }) 42 - .returning(); 43 - let [identity] = await tx 44 - .insert(identities) 45 - .values({ home_page: permissionToken.id, ...data }) 46 - .returning(); 47 - return identity as Omit<typeof identity, "interface_state"> & { 48 - interface_state: Json; 49 - }; 50 - }); 51 - }
+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));
+7 -3
actions/emailAuth.ts
··· 6 6 import { email_auth_tokens, identities } from "drizzle/schema"; 7 7 import { and, eq } from "drizzle-orm"; 8 8 import { cookies } from "next/headers"; 9 - import { createIdentity } from "./createIdentity"; 10 9 import { setAuthToken } from "src/auth"; 11 10 import { pool } from "supabase/pool"; 11 + import { supabaseServerClient } from "supabase/serverClient"; 12 12 13 13 async function sendAuthCode(email: string, code: string) { 14 14 if (process.env.NODE_ENV === "development") { ··· 114 114 .from(identities) 115 115 .where(eq(identities.email, token.email)); 116 116 if (!identity) { 117 - let newIdentity = await createIdentity(db, { email: token.email }); 118 - identityID = newIdentity.id; 117 + const { data: newIdentity } = await supabaseServerClient 118 + .from("identities") 119 + .insert({ email: token.email }) 120 + .select() 121 + .single(); 122 + identityID = newIdentity!.id; 119 123 } else { 120 124 identityID = identity.id; 121 125 }
+7 -8
actions/login.ts
··· 4 4 import { 5 5 email_auth_tokens, 6 6 identities, 7 - entity_sets, 8 - entities, 9 - permission_tokens, 10 - permission_token_rights, 11 7 permission_token_on_homepage, 12 8 poll_votes_on_entity, 13 9 } from "drizzle/schema"; 14 10 import { and, eq, isNull } from "drizzle-orm"; 15 11 import { cookies } from "next/headers"; 16 12 import { redirect } from "next/navigation"; 17 - import { v7 } from "uuid"; 18 - import { createIdentity } from "./createIdentity"; 19 13 import { pool } from "supabase/pool"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 20 15 21 16 export async function loginWithEmailToken( 22 17 localLeaflets: { token: { id: string }; added_at: string }[], ··· 77 72 identity = existingIdentityFromCookie; 78 73 } 79 74 } else { 80 - // Create a new identity 81 - identity = await createIdentity(tx, { email: token.email }); 75 + const { data: newIdentity } = await supabaseServerClient 76 + .from("identities") 77 + .insert({ email: token.email }) 78 + .select() 79 + .single(); 80 + identity = newIdentity!; 82 81 } 83 82 } 84 83
-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 - }
+238 -25
actions/publishToPublication.ts
··· 2 2 3 3 import * as Y from "yjs"; 4 4 import * as base64 from "base64-js"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { getIdentityData } from "actions/getIdentityData"; 7 10 import { 8 11 AtpBaseClient, ··· 32 35 import { scanIndexLocal } from "src/replicache/utils"; 33 36 import type { Fact } from "src/replicache"; 34 37 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 39 import { ids } from "lexicons/api/lexicons"; 40 40 import { BlobRef } from "@atproto/lexicon"; 41 41 import { AtUri } from "@atproto/syntax"; ··· 50 50 ColorToRGBA, 51 51 } from "components/ThemeManager/colorToLexicons"; 52 52 import { parseColor } from "@react-stately/color"; 53 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 54 + import { v7 } from "uuid"; 55 + 56 + type PublishResult = 57 + | { success: true; rkey: string; record: PubLeafletDocument.Record } 58 + | { success: false; error: OAuthSessionError }; 53 59 54 60 export async function publishToPublication({ 55 61 root_entity, ··· 57 63 leaflet_id, 58 64 title, 59 65 description, 66 + tags, 67 + cover_image, 60 68 entitiesToDelete, 61 69 }: { 62 70 root_entity: string; ··· 64 72 leaflet_id: string; 65 73 title?: string; 66 74 description?: string; 75 + tags?: string[]; 76 + cover_image?: string | null; 67 77 entitiesToDelete?: string[]; 68 - }) { 69 - const oauthClient = await createOauthClient(); 78 + }): Promise<PublishResult> { 70 79 let identity = await getIdentityData(); 71 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 80 + if (!identity || !identity.atp_did) { 81 + return { 82 + success: false, 83 + error: { 84 + type: "oauth_session_expired", 85 + message: "Not authenticated", 86 + did: "", 87 + }, 88 + }; 89 + } 72 90 73 - let credentialSession = await oauthClient.restore(identity.atp_did); 91 + const sessionResult = await restoreOAuthSession(identity.atp_did); 92 + if (!sessionResult.ok) { 93 + return { success: false, error: sessionResult.error }; 94 + } 95 + let credentialSession = sessionResult.value; 74 96 let agent = new AtpBaseClient( 75 97 credentialSession.fetchHandler.bind(credentialSession), 76 98 ); ··· 134 156 theme = await extractThemeFromFacts(facts, root_entity, agent); 135 157 } 136 158 159 + // Upload cover image if provided 160 + let coverImageBlob: BlobRef | undefined; 161 + if (cover_image) { 162 + let scan = scanIndexLocal(facts); 163 + let [imageData] = scan.eav(cover_image, "block/image"); 164 + if (imageData) { 165 + let imageResponse = await fetch(imageData.data.src); 166 + if (imageResponse.status === 200) { 167 + let binary = await imageResponse.blob(); 168 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 169 + headers: { "Content-Type": binary.type }, 170 + }); 171 + coverImageBlob = blob.data.blob; 172 + } 173 + } 174 + } 175 + 137 176 let record: PubLeafletDocument.Record = { 138 177 publishedAt: new Date().toISOString(), 139 178 ...existingRecord, ··· 143 182 ...(theme && { theme }), 144 183 title: title || "Untitled", 145 184 description: description || "", 185 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 186 + ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 146 187 pages: pages.map((p) => { 147 188 if (p.type === "canvas") { 148 189 return { ··· 210 251 } 211 252 } 212 253 213 - return { rkey, record: JSON.parse(JSON.stringify(record)) }; 254 + // Create notifications for mentions (only on first publish) 255 + if (!existingDocUri) { 256 + await createMentionNotifications(result.uri, record, credentialSession.did!); 257 + } 258 + 259 + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 214 260 } 215 261 216 262 async function processBlocksToPages( ··· 298 344 if (!b) return []; 299 345 let block: PubLeafletPagesLinearDocument.Block = { 300 346 $type: "pub.leaflet.pages.linearDocument#block", 301 - alignment, 302 347 block: b, 303 348 }; 349 + if (alignment) block.alignment = alignment; 304 350 return [block]; 305 351 } else { 306 352 let block: PubLeafletPagesLinearDocument.Block = { ··· 342 388 Y.applyUpdate(doc, update); 343 389 let nodes = doc.getXmlElement("prosemirror").toArray(); 344 390 let stringValue = YJSFragmentToString(nodes[0]); 345 - let facets = YJSFragmentToFacets(nodes[0]); 391 + let { facets } = YJSFragmentToFacets(nodes[0]); 346 392 return [stringValue, facets] as const; 347 393 }; 348 394 if (b.type === "card") { ··· 398 444 let [stringValue, facets] = getBlockContent(b.value); 399 445 let block: $Typed<PubLeafletBlocksHeader.Main> = { 400 446 $type: "pub.leaflet.blocks.header", 401 - level: headingLevel?.data.value || 1, 447 + level: Math.floor(headingLevel?.data.value || 1), 402 448 plaintext: stringValue, 403 449 facets, 404 450 }; ··· 431 477 let block: $Typed<PubLeafletBlocksIframe.Main> = { 432 478 $type: "pub.leaflet.blocks.iframe", 433 479 url: url.data.value, 434 - height: height?.data.value || 600, 480 + height: Math.floor(height?.data.value || 600), 435 481 }; 436 482 return block; 437 483 } ··· 445 491 $type: "pub.leaflet.blocks.image", 446 492 image: blobref, 447 493 aspectRatio: { 448 - height: image.data.height, 449 - width: image.data.width, 494 + height: Math.floor(image.data.height), 495 + width: Math.floor(image.data.width), 450 496 }, 451 497 alt: altText ? altText.data.value : undefined, 452 498 }; ··· 603 649 604 650 function YJSFragmentToFacets( 605 651 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 606 - ): PubLeafletRichtextFacet.Main[] { 652 + byteOffset: number = 0, 653 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 607 654 if (node.constructor === Y.XmlElement) { 608 - return node 609 - .toArray() 610 - .map((f) => YJSFragmentToFacets(f)) 611 - .flat(); 655 + // Handle inline mention nodes 656 + if (node.nodeName === "didMention") { 657 + const text = node.getAttribute("text") || ""; 658 + const unicodestring = new UnicodeString(text); 659 + const facet: PubLeafletRichtextFacet.Main = { 660 + index: { 661 + byteStart: byteOffset, 662 + byteEnd: byteOffset + unicodestring.length, 663 + }, 664 + features: [ 665 + { 666 + $type: "pub.leaflet.richtext.facet#didMention", 667 + did: node.getAttribute("did"), 668 + }, 669 + ], 670 + }; 671 + return { facets: [facet], byteLength: unicodestring.length }; 672 + } 673 + 674 + if (node.nodeName === "atMention") { 675 + const text = node.getAttribute("text") || ""; 676 + const unicodestring = new UnicodeString(text); 677 + const facet: PubLeafletRichtextFacet.Main = { 678 + index: { 679 + byteStart: byteOffset, 680 + byteEnd: byteOffset + unicodestring.length, 681 + }, 682 + features: [ 683 + { 684 + $type: "pub.leaflet.richtext.facet#atMention", 685 + atURI: node.getAttribute("atURI"), 686 + }, 687 + ], 688 + }; 689 + return { facets: [facet], byteLength: unicodestring.length }; 690 + } 691 + 692 + if (node.nodeName === "hard_break") { 693 + const unicodestring = new UnicodeString("\n"); 694 + return { facets: [], byteLength: unicodestring.length }; 695 + } 696 + 697 + // For other elements (like paragraph), process children 698 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 699 + let currentOffset = byteOffset; 700 + for (const child of node.toArray()) { 701 + const result = YJSFragmentToFacets(child, currentOffset); 702 + allFacets.push(...result.facets); 703 + currentOffset += result.byteLength; 704 + } 705 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 612 706 } 707 + 613 708 if (node.constructor === Y.XmlText) { 614 709 let facets: PubLeafletRichtextFacet.Main[] = []; 615 710 let delta = node.toDelta() as Delta[]; 616 - let byteStart = 0; 711 + let byteStart = byteOffset; 712 + let totalLength = 0; 617 713 for (let d of delta) { 618 714 let unicodestring = new UnicodeString(d.insert); 619 715 let facet: PubLeafletRichtextFacet.Main = { ··· 646 742 }); 647 743 if (facet.features.length > 0) facets.push(facet); 648 744 byteStart += unicodestring.length; 745 + totalLength += unicodestring.length; 649 746 } 650 - return facets; 747 + return { facets, byteLength: totalLength }; 651 748 } 652 - return []; 749 + return { facets: [], byteLength: 0 }; 653 750 } 654 751 655 752 type ExcludeString<T> = T extends string ··· 712 809 image: blob.data.blob, 713 810 repeat: backgroundImageRepeat?.data.value ? true : false, 714 811 ...(backgroundImageRepeat?.data.value && { 715 - width: backgroundImageRepeat.data.value, 812 + width: Math.floor(backgroundImageRepeat.data.value), 716 813 }), 717 814 }; 718 815 } ··· 725 822 726 823 return undefined; 727 824 } 825 + 826 + /** 827 + * Extract mentions from a published document and create notifications 828 + */ 829 + async function createMentionNotifications( 830 + documentUri: string, 831 + record: PubLeafletDocument.Record, 832 + authorDid: string, 833 + ) { 834 + const mentionedDids = new Set<string>(); 835 + const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 836 + const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 837 + 838 + // Extract mentions from all text blocks in all pages 839 + for (const page of record.pages) { 840 + if (page.$type === "pub.leaflet.pages.linearDocument") { 841 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 842 + for (const blockWrapper of linearPage.blocks) { 843 + const block = blockWrapper.block; 844 + if (block.$type === "pub.leaflet.blocks.text") { 845 + const textBlock = block as PubLeafletBlocksText.Main; 846 + if (textBlock.facets) { 847 + for (const facet of textBlock.facets) { 848 + for (const feature of facet.features) { 849 + // Check for DID mentions 850 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 851 + if (feature.did !== authorDid) { 852 + mentionedDids.add(feature.did); 853 + } 854 + } 855 + // Check for AT URI mentions (publications and documents) 856 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 857 + const uri = new AtUri(feature.atURI); 858 + 859 + if (uri.collection === "pub.leaflet.publication") { 860 + // Get the publication owner's DID 861 + const { data: publication } = await supabaseServerClient 862 + .from("publications") 863 + .select("identity_did") 864 + .eq("uri", feature.atURI) 865 + .single(); 866 + 867 + if (publication && publication.identity_did !== authorDid) { 868 + mentionedPublications.set(publication.identity_did, feature.atURI); 869 + } 870 + } else if (uri.collection === "pub.leaflet.document") { 871 + // Get the document owner's DID 872 + const { data: document } = await supabaseServerClient 873 + .from("documents") 874 + .select("uri, data") 875 + .eq("uri", feature.atURI) 876 + .single(); 877 + 878 + if (document) { 879 + const docRecord = document.data as PubLeafletDocument.Record; 880 + if (docRecord.author !== authorDid) { 881 + mentionedDocuments.set(docRecord.author, feature.atURI); 882 + } 883 + } 884 + } 885 + } 886 + } 887 + } 888 + } 889 + } 890 + } 891 + } 892 + } 893 + 894 + // Create notifications for DID mentions 895 + for (const did of mentionedDids) { 896 + const notification: Notification = { 897 + id: v7(), 898 + recipient: did, 899 + data: { 900 + type: "mention", 901 + document_uri: documentUri, 902 + mention_type: "did", 903 + }, 904 + }; 905 + await supabaseServerClient.from("notifications").insert(notification); 906 + await pingIdentityToUpdateNotification(did); 907 + } 908 + 909 + // Create notifications for publication mentions 910 + for (const [recipientDid, publicationUri] of mentionedPublications) { 911 + const notification: Notification = { 912 + id: v7(), 913 + recipient: recipientDid, 914 + data: { 915 + type: "mention", 916 + document_uri: documentUri, 917 + mention_type: "publication", 918 + mentioned_uri: publicationUri, 919 + }, 920 + }; 921 + await supabaseServerClient.from("notifications").insert(notification); 922 + await pingIdentityToUpdateNotification(recipientDid); 923 + } 924 + 925 + // Create notifications for document mentions 926 + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 927 + const notification: Notification = { 928 + id: v7(), 929 + recipient: recipientDid, 930 + data: { 931 + type: "mention", 932 + document_uri: documentUri, 933 + mention_type: "document", 934 + mentioned_uri: mentionedDocUri, 935 + }, 936 + }; 937 + await supabaseServerClient.from("notifications").insert(notification); 938 + await pingIdentityToUpdateNotification(recipientDid); 939 + } 940 + }
+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";
+1 -3
app/(home-pages)/discover/page.tsx
··· 17 17 return ( 18 18 <DashboardLayout 19 19 id="discover" 20 - cardBorderHidden={false} 21 20 currentPage="discover" 22 21 defaultTab="default" 23 22 actions={null} ··· 32 31 } 33 32 34 33 const DiscoverContent = async (props: { order: string }) => { 35 - const orderValue = 36 - props.order === "popular" ? "popular" : "recentlyUpdated"; 34 + const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 37 35 let { publications, nextCursor } = await getPublications(orderValue); 38 36 39 37 return (
+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/Menu"; 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 -22
app/(home-pages)/home/HomeLayout.tsx
··· 20 20 useDashboardState, 21 21 } from "components/PageLayouts/DashboardLayout"; 22 22 import { Actions } from "./Actions/Actions"; 23 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 23 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 25 24 import { useState } from "react"; 26 25 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 29 28 HomeEmptyState, 30 29 PublicationBanner, 31 30 } from "./HomeEmpty/HomeEmpty"; 32 - import { EmptyState } from "components/EmptyState"; 33 31 34 32 export type Leaflet = { 35 33 added_at: string; ··· 57 55 props.entityID, 58 56 "theme/background-image", 59 57 ); 60 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 61 58 62 59 let [searchValue, setSearchValue] = useState(""); 63 60 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 82 79 return ( 83 80 <DashboardLayout 84 81 id="home" 85 - cardBorderHidden={cardBorderHidden} 86 82 currentPage="home" 87 83 defaultTab="home" 88 84 actions={<Actions />} ··· 102 98 <HomeLeafletList 103 99 titles={props.titles} 104 100 initialFacts={props.initialFacts} 105 - cardBorderHidden={cardBorderHidden} 106 101 searchValue={debouncedSearchValue} 107 102 /> 108 103 ), ··· 118 113 [root_entity: string]: Fact<Attribute>[]; 119 114 }; 120 115 searchValue: string; 121 - cardBorderHidden: boolean; 122 116 }) { 123 117 let { identity } = useIdentityData(); 124 118 let { data: initialFacts } = useSWR( ··· 136 130 (acc, tok) => { 137 131 let title = 138 132 tok.permission_tokens.leaflets_in_publications[0]?.title || 139 - tok.permission_tokens.leaflets_to_documents?.title; 133 + tok.permission_tokens.leaflets_to_documents[0]?.title; 140 134 if (title) acc[tok.permission_tokens.root_entity] = title; 141 135 return acc; 142 136 }, ··· 172 166 searchValue={props.searchValue} 173 167 leaflets={leaflets} 174 168 titles={initialFacts?.titles || {}} 175 - cardBorderHidden={props.cardBorderHidden} 176 169 initialFacts={initialFacts?.facts || {}} 177 170 showPreview 178 171 /> ··· 193 186 [root_entity: string]: Fact<Attribute>[]; 194 187 }; 195 188 searchValue: string; 196 - cardBorderHidden: boolean; 197 189 showPreview?: boolean; 198 190 }) { 199 191 let { identity } = useIdentityData(); ··· 212 204 className={` 213 205 leafletList 214 206 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 "} `} 207 + ${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 208 > 217 - {searchedLeaflets.length === 0 && ( 218 - <EmptyState> 219 - <div className="italic">Oh no! No results!</div> 220 - </EmptyState> 221 - )} 222 209 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 223 210 <ReplicacheProvider 224 211 disablePull ··· 233 220 value={{ 234 221 ...leaflet, 235 222 leaflets_in_publications: leaflet.leaflets_in_publications || [], 236 - leaflets_to_documents: leaflet.leaflets_to_documents || null, 223 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 237 224 blocked_by_admin: null, 238 225 custom_domain_routes: [], 239 226 }} ··· 244 231 loggedIn={!!identity} 245 232 display={display} 246 233 added_at={added_at} 247 - cardBorderHidden={props.cardBorderHidden} 248 234 index={index} 249 235 showPreview={props.showPreview} 250 236 isHidden={ ··· 292 278 ({ token: leaflet, archived: archived }) => { 293 279 let published = 294 280 !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 295 - !!leaflet.leaflets_to_documents?.document; 281 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 296 282 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 283 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 - // If no filters are active, show all 284 + 285 + // If no filters are active, show everything that is not archived 299 286 if ( 300 287 !filter.drafts && 301 288 !filter.published && ··· 304 291 ) 305 292 return archived === false || archived === null || archived == undefined; 306 293 294 + //if a filter is on, return itemsd of that filter that are also NOT archived 307 295 return ( 308 - (filter.drafts && drafts) || 309 - (filter.published && published) || 310 - (filter.docs && docs) || 296 + (filter.drafts && drafts && !archived) || 297 + (filter.published && published && !archived) || 298 + (filter.docs && docs && !archived) || 311 299 (filter.archived && archived) 312 300 ); 313 301 },
+6 -5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 4 4 import { useState, useRef, useEffect } from "react"; 5 5 import { SpeedyLink } from "components/SpeedyLink"; 6 6 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 8 8 9 export const LeafletListItem = (props: { 9 10 archived?: boolean | null; 10 11 loggedIn: boolean; 11 12 display: "list" | "grid"; 12 - cardBorderHidden: boolean; 13 13 added_at: string; 14 14 title?: string; 15 15 index: number; 16 16 isHidden: boolean; 17 17 showPreview?: boolean; 18 18 }) => { 19 + const cardBorderHidden = useCardBorderHidden(); 19 20 const pubStatus = useLeafletPublicationStatus(); 20 21 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 21 22 let previewRef = useRef<HTMLDivElement | null>(null); ··· 47 48 ref={previewRef} 48 49 className={`relative flex gap-3 w-full 49 50 ${props.isHidden ? "hidden" : "flex"} 50 - ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 52 style={{ 52 - backgroundColor: props.cardBorderHidden 53 + backgroundColor: cardBorderHidden 53 54 ? "transparent" 54 55 : "rgba(var(--bg-page), var(--bg-page-alpha))", 55 56 }} ··· 67 68 loggedIn={props.loggedIn} 68 69 /> 69 70 </div> 70 - {props.cardBorderHidden && ( 71 + {cardBorderHidden && ( 71 72 <hr 72 73 className="last:hidden border-border-light" 73 74 style={{ ··· 87 88 ${props.isHidden ? "hidden" : "flex"} 88 89 `} 89 90 style={{ 90 - backgroundColor: props.cardBorderHidden 91 + backgroundColor: cardBorderHidden 91 92 ? "transparent" 92 93 : "rgba(var(--bg-page), var(--bg-page-alpha))", 93 94 }}
+2 -2
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 1 1 "use client"; 2 2 3 - import { Menu, MenuItem } from "components/Layout"; 3 + import { Menu, MenuItem } from "components/Menu"; 4 4 import { useState } from "react"; 5 5 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 6 import { useToaster } from "components/Toast"; ··· 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!"
+18 -7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
··· 18 18 const firstPage = useEntity(root, "root/page")[0]; 19 19 const page = firstPage?.data.value || root; 20 20 21 - const cardBorderHidden = useCardBorderHidden(root); 21 + const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data 22 + .value; 22 23 const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 23 24 const rootBackgroundRepeat = useEntity( 24 25 root, ··· 49 50 50 51 const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`; 51 52 52 - return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass }; 53 + return { 54 + root, 55 + page, 56 + cardBorderHidden, 57 + contentWrapperStyle, 58 + contentWrapperClass, 59 + }; 53 60 } 54 61 55 62 export const LeafletListPreview = (props: { isVisible: boolean }) => { 56 - const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } = 57 - useLeafletPreviewData(); 63 + const { 64 + root, 65 + page, 66 + cardBorderHidden, 67 + contentWrapperStyle, 68 + contentWrapperClass, 69 + } = useLeafletPreviewData(); 58 70 59 71 return ( 60 72 <Tooltip 61 - open={true} 62 - delayDuration={0} 63 73 side="right" 74 + asChild 64 75 trigger={ 65 - <div className="w-12 h-full py-1"> 76 + <div className="w-12 h-full py-1 z-10"> 66 77 <div className="rounded-md h-full overflow-hidden"> 67 78 <ThemeProvider local entityID={root} className=""> 68 79 <ThemeBackgroundProvider entityID={root}>
+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 -56
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 1 "use client"; 2 - import { 3 - DashboardLayout, 4 - PublicationDashboardControls, 5 - } from "components/PageLayouts/DashboardLayout"; 6 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 7 3 import { useState } from "react"; 8 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 9 5 import { Fact, PermissionToken } from "src/replicache"; ··· 14 10 import useSWR from "swr"; 15 11 import { getHomeDocs } from "../home/storage"; 16 12 import { Leaflet, LeafletList } from "../home/HomeLayout"; 17 - import { EmptyState } from "components/EmptyState"; 18 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 19 13 20 14 export const LooseleafsLayout = (props: { 21 15 entityID: string | null; ··· 35 29 [searchValue], 36 30 ); 37 31 38 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 39 32 return ( 40 33 <DashboardLayout 41 34 id="looseleafs" 42 - cardBorderHidden={cardBorderHidden} 43 35 currentPage="looseleafs" 44 - defaultTab="Drafts" 36 + defaultTab="home" 45 37 actions={<Actions />} 46 38 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: { 39 + home: { 59 40 controls: null, 60 41 content: ( 61 42 <LooseleafList 62 43 titles={props.titles} 63 44 initialFacts={props.initialFacts} 64 - cardBorderHidden={cardBorderHidden} 65 45 searchValue={debouncedSearchValue} 66 46 /> 67 47 ), ··· 71 51 ); 72 52 }; 73 53 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 54 export const LooseleafList = (props: { 91 55 titles: { [root_entity: string]: string }; 92 56 initialFacts: { 93 57 [root_entity: string]: Fact<Attribute>[]; 94 58 }; 95 59 searchValue: string; 96 - cardBorderHidden: boolean; 97 60 }) => { 98 61 let { identity } = useIdentityData(); 99 62 let { data: initialFacts } = useSWR( ··· 111 74 (acc, tok) => { 112 75 let title = 113 76 tok.permission_tokens.leaflets_in_publications[0]?.title || 114 - tok.permission_tokens.leaflets_to_documents?.title; 77 + tok.permission_tokens.leaflets_to_documents[0]?.title; 115 78 if (title) acc[tok.permission_tokens.root_entity] = title; 116 79 return acc; 117 80 }, ··· 127 90 let leaflets: Leaflet[] = identity 128 91 ? identity.permission_token_on_homepage 129 92 .filter( 130 - (ptoh) => 131 - ptoh.permission_tokens.leaflets_to_documents && 132 - ptoh.permission_tokens.leaflets_to_documents.document, 93 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 133 94 ) 134 95 .map((ptoh) => ({ 135 96 added_at: ptoh.created_at, 136 97 token: ptoh.permission_tokens as PermissionToken, 137 98 })) 138 99 : []; 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 100 return ( 151 101 <LeafletList 152 102 defaultDisplay="list" 153 103 searchValue={props.searchValue} 154 104 leaflets={leaflets} 155 105 titles={initialFacts?.titles || {}} 156 - cardBorderHidden={props.cardBorderHidden} 157 106 initialFacts={initialFacts?.facts || {}} 158 107 showPreview 159 108 />
+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 + };
-1
app/(home-pages)/notifications/page.tsx
··· 10 10 return ( 11 11 <DashboardLayout 12 12 id="discover" 13 - cardBorderHidden={true} 14 13 currentPage="notifications" 15 14 defaultTab="default" 16 15 actions={null}
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
··· 1 + "use client"; 2 + 3 + import { PostListing } from "components/PostListing"; 4 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "./getProfilePosts"; 6 + import { getProfilePosts } from "./getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + 10 + export const ProfilePostsContent = (props: { 11 + did: string; 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, did, cursor]) => getProfilePosts(did, cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) { 67 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + } 69 + 70 + return ( 71 + <div className="flex flex-col gap-3 text-left relative"> 72 + {allPosts.map((post) => ( 73 + <PostListing key={post.documents.uri} {...post} /> 74 + ))} 75 + {/* Trigger element for loading more posts */} 76 + <div 77 + ref={loadMoreRef} 78 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 79 + aria-hidden="true" 80 + /> 81 + {isValidating && ( 82 + <div className="text-center text-tertiary py-4"> 83 + Loading more posts... 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import type { ProfileData } from "./layout"; 6 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 + import { PubIcon } from "components/ActionBar/Publications"; 9 + import { Json } from "supabase/database.types"; 10 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 11 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 + import { SpeedyLink } from "components/SpeedyLink"; 13 + import { ReactNode } from "react"; 14 + import * as linkify from "linkifyjs"; 15 + 16 + export const ProfileHeader = (props: { 17 + profile: ProfileViewDetailed; 18 + publications: { record: Json; uri: string }[]; 19 + popover?: boolean; 20 + }) => { 21 + let profileRecord = props.profile; 22 + const profileUrl = `/p/${props.profile.handle}`; 23 + 24 + const avatarElement = ( 25 + <Avatar 26 + src={profileRecord.avatar} 27 + displayName={profileRecord.displayName} 28 + className="mx-auto mt-3 sm:mt-4" 29 + giant 30 + /> 31 + ); 32 + 33 + const displayNameElement = ( 34 + <h3 className=" px-3 sm:px-4 pt-2 leading-tight"> 35 + {profileRecord.displayName 36 + ? profileRecord.displayName 37 + : `@${props.profile.handle}`} 38 + </h3> 39 + ); 40 + 41 + const handleElement = profileRecord.displayName && ( 42 + <div 43 + className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`} 44 + > 45 + @{props.profile.handle} 46 + </div> 47 + ); 48 + 49 + return ( 50 + <div 51 + className={`flex flex-col relative ${props.popover && "text-sm"}`} 52 + id="profile-header" 53 + > 54 + <ProfileLinks handle={props.profile.handle || ""} /> 55 + <div className="flex flex-col"> 56 + <div className="flex flex-col group"> 57 + {props.popover ? ( 58 + <SpeedyLink className={"hover:no-underline!"} href={profileUrl}> 59 + {avatarElement} 60 + </SpeedyLink> 61 + ) : ( 62 + avatarElement 63 + )} 64 + {props.popover ? ( 65 + <SpeedyLink 66 + className={" text-primary group-hover:underline"} 67 + href={profileUrl} 68 + > 69 + {displayNameElement} 70 + </SpeedyLink> 71 + ) : ( 72 + displayNameElement 73 + )} 74 + {props.popover && handleElement ? ( 75 + <SpeedyLink className={"group-hover:underline"} href={profileUrl}> 76 + {handleElement} 77 + </SpeedyLink> 78 + ) : ( 79 + handleElement 80 + )} 81 + </div> 82 + <pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap"> 83 + {profileRecord.description 84 + ? parseDescription(profileRecord.description) 85 + : null} 86 + </pre> 87 + <div className=" w-full overflow-x-scroll py-3 mb-3 "> 88 + <div 89 + className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 90 + > 91 + {props.publications.map((p) => ( 92 + <PublicationCard 93 + key={p.uri} 94 + record={p.record as PubLeafletPublication.Record} 95 + uri={p.uri} 96 + /> 97 + ))} 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + ); 103 + }; 104 + 105 + const ProfileLinks = (props: { handle: string }) => { 106 + return ( 107 + <div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2"> 108 + <a 109 + className="text-tertiary hover:text-accent-contrast hover:no-underline!" 110 + href={`https://bsky.app/profile/${props.handle}`} 111 + > 112 + <BlueskyTiny /> 113 + </a> 114 + </div> 115 + ); 116 + }; 117 + const PublicationCard = (props: { 118 + record: PubLeafletPublication.Record; 119 + uri: string; 120 + }) => { 121 + const { record, uri } = props; 122 + const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 123 + 124 + return ( 125 + <a 126 + href={`https://${record.base_path}`} 127 + className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 128 + style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 129 + > 130 + <div 131 + className="rounded-md p-2 flex flex-row gap-2" 132 + style={{ 133 + backgroundColor: record.theme?.showPageBackground 134 + ? `rgb(${colorToString(bgPage, "rgb")})` 135 + : undefined, 136 + }} 137 + > 138 + <PubIcon record={record} uri={uri} /> 139 + <h4 140 + className="truncate min-w-0" 141 + style={{ 142 + color: `rgb(${colorToString(primary, "rgb")})`, 143 + }} 144 + > 145 + {record.name} 146 + </h4> 147 + </div> 148 + </a> 149 + ); 150 + }; 151 + 152 + function parseDescription(description: string): ReactNode[] { 153 + // Find all mentions using regex 154 + const mentionRegex = /@\S+/g; 155 + const mentions: { start: number; end: number; value: string }[] = []; 156 + let mentionMatch; 157 + while ((mentionMatch = mentionRegex.exec(description)) !== null) { 158 + mentions.push({ 159 + start: mentionMatch.index, 160 + end: mentionMatch.index + mentionMatch[0].length, 161 + value: mentionMatch[0], 162 + }); 163 + } 164 + 165 + // Find all URLs using linkifyjs 166 + const links = linkify.find(description).filter((link) => link.type === "url"); 167 + 168 + // Filter out URLs that overlap with mentions (mentions take priority) 169 + const nonOverlappingLinks = links.filter((link) => { 170 + return !mentions.some( 171 + (mention) => 172 + (link.start >= mention.start && link.start < mention.end) || 173 + (link.end > mention.start && link.end <= mention.end) || 174 + (link.start <= mention.start && link.end >= mention.end), 175 + ); 176 + }); 177 + 178 + // Combine into a single sorted list 179 + const allMatches: Array<{ 180 + start: number; 181 + end: number; 182 + value: string; 183 + href: string; 184 + type: "url" | "mention"; 185 + }> = [ 186 + ...nonOverlappingLinks.map((link) => ({ 187 + start: link.start, 188 + end: link.end, 189 + value: link.value, 190 + href: link.href, 191 + type: "url" as const, 192 + })), 193 + ...mentions.map((mention) => ({ 194 + start: mention.start, 195 + end: mention.end, 196 + value: mention.value, 197 + href: `/p/${mention.value.slice(1)}`, 198 + type: "mention" as const, 199 + })), 200 + ].sort((a, b) => a.start - b.start); 201 + 202 + const parts: ReactNode[] = []; 203 + let lastIndex = 0; 204 + let key = 0; 205 + 206 + for (const match of allMatches) { 207 + // Add text before this match 208 + if (match.start > lastIndex) { 209 + parts.push(description.slice(lastIndex, match.start)); 210 + } 211 + 212 + if (match.type === "mention") { 213 + parts.push( 214 + <SpeedyLink key={key++} href={match.href}> 215 + {match.value} 216 + </SpeedyLink>, 217 + ); 218 + } else { 219 + // It's a URL 220 + const urlWithoutProtocol = match.value 221 + .replace(/^https?:\/\//, "") 222 + .replace(/\/+$/, ""); 223 + const displayText = 224 + urlWithoutProtocol.length > 50 225 + ? urlWithoutProtocol.slice(0, 50) + "โ€ฆ" 226 + : urlWithoutProtocol; 227 + parts.push( 228 + <a key={key++} href={match.href} target="_blank" rel="noopener noreferrer"> 229 + {displayText} 230 + </a>, 231 + ); 232 + } 233 + 234 + lastIndex = match.end; 235 + } 236 + 237 + // Add remaining text after last match 238 + if (lastIndex < description.length) { 239 + parts.push(description.slice(lastIndex)); 240 + } 241 + 242 + return parts; 243 + }
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 1 + "use client"; 2 + 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + 5 + export function ProfileLayout(props: { children: React.ReactNode }) { 6 + let cardBorderHidden = useCardBorderHidden(); 7 + return ( 8 + <div 9 + id="profile-content" 10 + className={` 11 + ${ 12 + cardBorderHidden 13 + ? "" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 15 + } 16 + max-w-prose mx-auto w-full 17 + flex flex-col 18 + text-center 19 + `} 20 + > 21 + {props.children} 22 + </div> 23 + ); 24 + }
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
··· 1 + "use client"; 2 + 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + import { useState, useEffect } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + 8 + export type ProfileTabType = "posts" | "comments" | "subscriptions"; 9 + 10 + export const ProfileTabs = (props: { didOrHandle: string }) => { 11 + const cardBorderHidden = useCardBorderHidden(); 12 + const segment = useSelectedLayoutSegment(); 13 + const currentTab = (segment || "posts") as ProfileTabType; 14 + const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0); 15 + const [headerHeight, setHeaderHeight] = useState(0); 16 + useEffect(() => { 17 + let headerHeight = 18 + document.getElementById("profile-header")?.clientHeight || 0; 19 + setHeaderHeight(headerHeight); 20 + 21 + const profileContent = cardBorderHidden 22 + ? document.getElementById("home-content") 23 + : document.getElementById("profile-content"); 24 + const handleScroll = () => { 25 + if (profileContent) { 26 + setScrollPosWithinTabContent( 27 + profileContent.scrollTop - headerHeight > 0 28 + ? profileContent.scrollTop - headerHeight 29 + : 0, 30 + ); 31 + } 32 + }; 33 + 34 + if (profileContent) { 35 + profileContent.addEventListener("scroll", handleScroll); 36 + return () => profileContent.removeEventListener("scroll", handleScroll); 37 + } 38 + }, []); 39 + 40 + const baseUrl = `/p/${props.didOrHandle}`; 41 + const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 + 43 + return ( 44 + <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3"> 45 + <div 46 + style={ 47 + scrollPosWithinTabContent < 20 48 + ? { 49 + paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 50 + paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 51 + } 52 + : { paddingLeft: "12px", paddingRight: "12px" } 53 + } 54 + > 55 + <div 56 + className={` 57 + border rounded-lg 58 + ${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"} 59 + py-1 60 + w-full `} 61 + style={ 62 + scrollPosWithinTabContent < 20 63 + ? { 64 + backgroundColor: !cardBorderHidden 65 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 66 + : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 + paddingLeft: !cardBorderHidden 68 + ? "4px" 69 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 70 + paddingRight: !cardBorderHidden 71 + ? "4px" 72 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 73 + } 74 + : { 75 + backgroundColor: `rgb(${bgColor})`, 76 + paddingLeft: "4px", 77 + paddingRight: "4px", 78 + } 79 + } 80 + > 81 + <div className="flex gap-2 justify-between"> 82 + <div className="flex gap-2"> 83 + <TabLink 84 + href={baseUrl} 85 + name="Posts" 86 + selected={currentTab === "posts"} 87 + /> 88 + <TabLink 89 + href={`${baseUrl}/comments`} 90 + name="Comments" 91 + selected={currentTab === "comments"} 92 + /> 93 + </div> 94 + <TabLink 95 + href={`${baseUrl}/subscriptions`} 96 + name="Subscriptions" 97 + selected={currentTab === "subscriptions"} 98 + /> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + ); 104 + }; 105 + 106 + const TabLink = (props: { href: string; name: string; selected: boolean }) => { 107 + return ( 108 + <SpeedyLink 109 + href={props.href} 110 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 + props.selected 112 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 + : "text-tertiary" 114 + }`} 115 + > 116 + {props.name} 117 + </SpeedyLink> 118 + ); 119 + };
+222
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useMemo } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 + import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { 12 + getProfileComments, 13 + type ProfileComment, 14 + type Cursor, 15 + } from "./getProfileComments"; 16 + import { timeAgo } from "src/utils/timeAgo"; 17 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 18 + 19 + export const ProfileCommentsContent = (props: { 20 + did: string; 21 + comments: ProfileComment[]; 22 + nextCursor: Cursor | null; 23 + }) => { 24 + const getKey = ( 25 + pageIndex: number, 26 + previousPageData: { 27 + comments: ProfileComment[]; 28 + nextCursor: Cursor | null; 29 + } | null, 30 + ) => { 31 + // Reached the end 32 + if (previousPageData && !previousPageData.nextCursor) return null; 33 + 34 + // First page, we don't have previousPageData 35 + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; 36 + 37 + // Add the cursor to the key 38 + return [ 39 + "profile-comments", 40 + props.did, 41 + previousPageData?.nextCursor, 42 + ] as const; 43 + }; 44 + 45 + const { data, size, setSize, isValidating } = useSWRInfinite( 46 + getKey, 47 + ([_, did, cursor]) => getProfileComments(did, cursor), 48 + { 49 + fallbackData: [ 50 + { comments: props.comments, nextCursor: props.nextCursor }, 51 + ], 52 + revalidateFirstPage: false, 53 + }, 54 + ); 55 + 56 + const loadMoreRef = useRef<HTMLDivElement>(null); 57 + 58 + // Set up intersection observer to load more when trigger element is visible 59 + useEffect(() => { 60 + const observer = new IntersectionObserver( 61 + (entries) => { 62 + if (entries[0].isIntersecting && !isValidating) { 63 + const hasMore = data && data[data.length - 1]?.nextCursor; 64 + if (hasMore) { 65 + setSize(size + 1); 66 + } 67 + } 68 + }, 69 + { threshold: 0.1 }, 70 + ); 71 + 72 + if (loadMoreRef.current) { 73 + observer.observe(loadMoreRef.current); 74 + } 75 + 76 + return () => observer.disconnect(); 77 + }, [data, size, setSize, isValidating]); 78 + 79 + const allComments = data ? data.flatMap((page) => page.comments) : []; 80 + 81 + if (allComments.length === 0 && !isValidating) { 82 + return ( 83 + <div className="text-tertiary text-center py-4">No comments yet</div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div className="flex flex-col gap-2 text-left relative"> 89 + {allComments.map((comment) => ( 90 + <CommentItem key={comment.uri} comment={comment} /> 91 + ))} 92 + {/* Trigger element for loading more comments */} 93 + <div 94 + ref={loadMoreRef} 95 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 + aria-hidden="true" 97 + /> 98 + {isValidating && ( 99 + <div className="text-center text-tertiary py-4"> 100 + Loading more comments... 101 + </div> 102 + )} 103 + </div> 104 + ); 105 + }; 106 + 107 + const CommentItem = ({ comment }: { comment: ProfileComment }) => { 108 + const record = comment.record as PubLeafletComment.Record; 109 + const profile = comment.bsky_profiles?.record as 110 + | AppBskyActorProfile.Record 111 + | undefined; 112 + const displayName = 113 + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 114 + 115 + // Get commenter DID from comment URI 116 + const commenterDid = new AtUri(comment.uri).host; 117 + 118 + const isReply = !!record.reply; 119 + 120 + // Get document title 121 + const docData = comment.document?.data as 122 + | PubLeafletDocument.Record 123 + | undefined; 124 + const postTitle = docData?.title || "Untitled"; 125 + 126 + // Get parent comment info for replies 127 + const parentRecord = comment.parentComment?.record as 128 + | PubLeafletComment.Record 129 + | undefined; 130 + const parentProfile = comment.parentComment?.bsky_profiles?.record as 131 + | AppBskyActorProfile.Record 132 + | undefined; 133 + const parentDisplayName = 134 + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 135 + 136 + // Build direct link to the comment 137 + const commentLink = useMemo(() => { 138 + if (!comment.document) return null; 139 + const docUri = new AtUri(comment.document.uri); 140 + 141 + // Get base URL using getPublicationURL if publication exists, otherwise build path 142 + let baseUrl: string; 143 + if (comment.publication) { 144 + baseUrl = getPublicationURL(comment.publication); 145 + const pubUri = new AtUri(comment.publication.uri); 146 + // If getPublicationURL returns a relative path, append the document rkey 147 + if (baseUrl.startsWith("/")) { 148 + baseUrl = `${baseUrl}/${docUri.rkey}`; 149 + } else { 150 + // For custom domains, append the document rkey 151 + baseUrl = `${baseUrl}/${docUri.rkey}`; 152 + } 153 + } else { 154 + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; 155 + } 156 + 157 + // Build query parameters 158 + const params = new URLSearchParams(); 159 + params.set("interactionDrawer", "comments"); 160 + if (record.onPage) { 161 + params.set("page", record.onPage); 162 + } 163 + 164 + // Use comment URI as hash for direct reference 165 + const commentId = encodeURIComponent(comment.uri); 166 + 167 + return `${baseUrl}?${params.toString()}#${commentId}`; 168 + }, [comment.document, comment.publication, comment.uri, record.onPage]); 169 + 170 + // Get avatar source 171 + const avatarSrc = profile?.avatar?.ref 172 + ? blobRefToSrc(profile.avatar.ref, commenterDid) 173 + : undefined; 174 + 175 + return ( 176 + <div id={comment.uri} className="w-full flex flex-col text-left mb-8"> 177 + <div className="flex gap-2 w-full"> 178 + <Avatar src={avatarSrc} displayName={displayName} /> 179 + <div className="flex flex-col w-full min-w-0 grow"> 180 + <div className="flex flex-row gap-2 justify-between"> 181 + <div className="text-tertiary text-sm truncate"> 182 + <span className="font-bold text-secondary">{displayName}</span>{" "} 183 + {isReply ? "replied" : "commented"} on{" "} 184 + {commentLink ? ( 185 + <a 186 + href={commentLink} 187 + className="italic text-accent-contrast hover:underline" 188 + > 189 + {postTitle} 190 + </a> 191 + ) : ( 192 + <span className="italic text-accent-contrast">{postTitle}</span> 193 + )} 194 + </div> 195 + <div className="text-tertiary text-sm shrink-0"> 196 + {timeAgo(record.createdAt)} 197 + </div> 198 + </div> 199 + {isReply && parentRecord && ( 200 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 201 + <ReplyTiny className="shrink-0 scale-75" /> 202 + {parentDisplayName && ( 203 + <div className="font-bold shrink-0">{parentDisplayName}</div> 204 + )} 205 + <div className="grow truncate">{parentRecord.plaintext}</div> 206 + </div> 207 + )} 208 + <pre 209 + style={{ wordBreak: "break-word" }} 210 + className="whitespace-pre-wrap text-secondary" 211 + > 212 + <BaseTextBlock 213 + index={[]} 214 + plaintext={record.plaintext} 215 + facets={record.facets} 216 + /> 217 + </pre> 218 + </div> 219 + </div> 220 + </div> 221 + ); 222 + };
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { PubLeafletComment } from "lexicons/api"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export type ProfileComment = { 13 + uri: string; 14 + record: Json; 15 + indexed_at: string; 16 + bsky_profiles: { record: Json; handle: string | null } | null; 17 + document: { 18 + uri: string; 19 + data: Json; 20 + } | null; 21 + publication: { 22 + uri: string; 23 + record: Json; 24 + } | null; 25 + // For replies, include the parent comment info 26 + parentComment: { 27 + uri: string; 28 + record: Json; 29 + bsky_profiles: { record: Json; handle: string | null } | null; 30 + } | null; 31 + }; 32 + 33 + export async function getProfileComments( 34 + did: string, 35 + cursor?: Cursor | null, 36 + ): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { 37 + const limit = 20; 38 + 39 + let query = supabaseServerClient 40 + .from("comments_on_documents") 41 + .select( 42 + `*, 43 + bsky_profiles(record, handle), 44 + documents(uri, data, documents_in_publications(publications(*)))`, 45 + ) 46 + .eq("profile", did) 47 + .order("indexed_at", { ascending: false }) 48 + .order("uri", { ascending: false }) 49 + .limit(limit); 50 + 51 + if (cursor) { 52 + query = query.or( 53 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 54 + ); 55 + } 56 + 57 + const { data: rawComments } = await query; 58 + 59 + if (!rawComments || rawComments.length === 0) { 60 + return { comments: [], nextCursor: null }; 61 + } 62 + 63 + // Collect parent comment URIs for replies 64 + const parentUris = rawComments 65 + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 + .filter((uri): uri is string => !!uri); 67 + 68 + // Fetch parent comments if there are any replies 69 + let parentCommentsMap = new Map< 70 + string, 71 + { 72 + uri: string; 73 + record: Json; 74 + bsky_profiles: { record: Json; handle: string | null } | null; 75 + } 76 + >(); 77 + 78 + if (parentUris.length > 0) { 79 + const { data: parentComments } = await supabaseServerClient 80 + .from("comments_on_documents") 81 + .select(`uri, record, bsky_profiles(record, handle)`) 82 + .in("uri", parentUris); 83 + 84 + if (parentComments) { 85 + for (const pc of parentComments) { 86 + parentCommentsMap.set(pc.uri, { 87 + uri: pc.uri, 88 + record: pc.record, 89 + bsky_profiles: pc.bsky_profiles, 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Transform to ProfileComment format 96 + const comments: ProfileComment[] = rawComments.map((comment) => { 97 + const record = comment.record as PubLeafletComment.Record; 98 + const doc = comment.documents; 99 + const pub = doc?.documents_in_publications?.[0]?.publications; 100 + 101 + return { 102 + uri: comment.uri, 103 + record: comment.record, 104 + indexed_at: comment.indexed_at, 105 + bsky_profiles: comment.bsky_profiles, 106 + document: doc 107 + ? { 108 + uri: doc.uri, 109 + data: doc.data, 110 + } 111 + : null, 112 + publication: pub 113 + ? { 114 + uri: pub.uri, 115 + record: pub.record, 116 + } 117 + : null, 118 + parentComment: record.reply?.parent 119 + ? parentCommentsMap.get(record.reply.parent) || null 120 + : null, 121 + }; 122 + }); 123 + 124 + const nextCursor = 125 + comments.length === limit 126 + ? { 127 + indexed_at: comments[comments.length - 1].indexed_at, 128 + uri: comments[comments.length - 1].uri, 129 + } 130 + : null; 131 + 132 + return { comments, nextCursor }; 133 + }
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfileComments } from "./getProfileComments"; 3 + import { ProfileCommentsContent } from "./CommentsContent"; 4 + 5 + export default async function ProfileCommentsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { comments, nextCursor } = await getProfileComments(did); 20 + 21 + return ( 22 + <ProfileCommentsContent 23 + did={did} 24 + comments={comments} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export async function getProfilePosts( 13 + did: string, 14 + cursor?: Cursor | null, 15 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 16 + const limit = 20; 17 + 18 + let query = supabaseServerClient 19 + .from("documents") 20 + .select( 21 + `*, 22 + comments_on_documents(count), 23 + document_mentions_in_bsky(count), 24 + documents_in_publications(publications(*))`, 25 + ) 26 + .like("uri", `at://${did}/%`) 27 + .order("indexed_at", { ascending: false }) 28 + .order("uri", { ascending: false }) 29 + .limit(limit); 30 + 31 + if (cursor) { 32 + query = query.or( 33 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 34 + ); 35 + } 36 + 37 + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 38 + query, 39 + supabaseServerClient 40 + .from("publications") 41 + .select("*") 42 + .eq("identity_did", did), 43 + supabaseServerClient 44 + .from("bsky_profiles") 45 + .select("handle") 46 + .eq("did", did) 47 + .single(), 48 + ]); 49 + 50 + // Build a map of publications for quick lookup 51 + let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 + for (let pub of pubs || []) { 53 + pubMap.set(pub.uri, pub); 54 + } 55 + 56 + // Transform data to Post[] format 57 + let handle = profile?.handle ? `@${profile.handle}` : null; 58 + let posts: Post[] = []; 59 + 60 + for (let doc of docs || []) { 61 + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 + 64 + let post: Post = { 65 + author: handle, 66 + documents: { 67 + data: doc.data, 68 + uri: doc.uri, 69 + indexed_at: doc.indexed_at, 70 + comments_on_documents: doc.comments_on_documents, 71 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 72 + }, 73 + }; 74 + 75 + if (pub) { 76 + post.publication = { 77 + href: getPublicationURL(pub), 78 + pubRecord: pub.record, 79 + uri: pub.uri, 80 + }; 81 + } 82 + 83 + posts.push(post); 84 + } 85 + 86 + const nextCursor = 87 + posts.length === limit 88 + ? { 89 + indexed_at: posts[posts.length - 1].documents.indexed_at, 90 + uri: posts[posts.length - 1].documents.uri, 91 + } 92 + : null; 93 + 94 + return { posts, nextCursor }; 95 + }
+112
app/(home-pages)/p/[didOrHandle]/layout.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { ProfileHeader } from "./ProfileHeader"; 6 + import { ProfileTabs } from "./ProfileTabs"; 7 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 8 + import { ProfileLayout } from "./ProfileLayout"; 9 + import { Agent } from "@atproto/api"; 10 + import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 11 + import { Metadata } from "next"; 12 + import { cache } from "react"; 13 + 14 + // Cache the profile data call to prevent concurrent OAuth restores 15 + const getCachedProfileData = cache(async (did: string) => { 16 + return get_profile_data.handler( 17 + { didOrHandle: did }, 18 + { supabase: supabaseServerClient }, 19 + ); 20 + }); 21 + 22 + export async function generateMetadata(props: { 23 + params: Promise<{ didOrHandle: string }>; 24 + }): Promise<Metadata> { 25 + let params = await props.params; 26 + let didOrHandle = decodeURIComponent(params.didOrHandle); 27 + 28 + let did = didOrHandle; 29 + if (!didOrHandle.startsWith("did:")) { 30 + let resolved = await idResolver.handle.resolve(didOrHandle); 31 + if (!resolved) return { title: "Profile - Leaflet" }; 32 + did = resolved; 33 + } 34 + 35 + let profileData = await getCachedProfileData(did); 36 + let { profile } = profileData.result; 37 + 38 + if (!profile) return { title: "Profile - Leaflet" }; 39 + 40 + const displayName = profile.displayName; 41 + const handle = profile.handle; 42 + 43 + const title = displayName 44 + ? `${displayName} (@${handle}) - Leaflet` 45 + : `@${handle} - Leaflet`; 46 + 47 + return { title }; 48 + } 49 + 50 + export default async function ProfilePageLayout(props: { 51 + params: Promise<{ didOrHandle: string }>; 52 + children: React.ReactNode; 53 + }) { 54 + let params = await props.params; 55 + let didOrHandle = decodeURIComponent(params.didOrHandle); 56 + 57 + // Resolve handle to DID if necessary 58 + let did = didOrHandle; 59 + 60 + if (!didOrHandle.startsWith("did:")) { 61 + let resolved = await idResolver.handle.resolve(didOrHandle); 62 + if (!resolved) { 63 + return ( 64 + <NotFoundLayout> 65 + <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 66 + <p> 67 + This may be a glitch on our end. If the issue persists please{" "} 68 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 69 + </p> 70 + </NotFoundLayout> 71 + ); 72 + } 73 + did = resolved; 74 + } 75 + let profileData = await getCachedProfileData(did); 76 + let { publications, profile } = profileData.result; 77 + 78 + if (!profile) return null; 79 + 80 + return ( 81 + <DashboardLayout 82 + id="profile" 83 + defaultTab="default" 84 + currentPage="profile" 85 + actions={null} 86 + tabs={{ 87 + default: { 88 + controls: null, 89 + content: ( 90 + <ProfileLayout> 91 + <ProfileHeader 92 + profile={profile} 93 + publications={publications || []} 94 + /> 95 + <ProfileTabs didOrHandle={params.didOrHandle} /> 96 + <div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 97 + {props.children} 98 + </div> 99 + </ProfileLayout> 100 + ), 101 + }, 102 + }} 103 + /> 104 + ); 105 + } 106 + 107 + export type ProfileData = { 108 + did: string; 109 + handle: string | null; 110 + indexed_at: string; 111 + record: Json; 112 + };
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfilePosts } from "./getProfilePosts"; 3 + import { ProfilePostsContent } from "./PostsContent"; 4 + 5 + export default async function ProfilePostsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { posts, nextCursor } = await getProfilePosts(did); 20 + 21 + return ( 22 + <ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} /> 23 + ); 24 + }
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 6 + import { 7 + getSubscriptions, 8 + type PublicationSubscription, 9 + } from "app/(home-pages)/reader/getSubscriptions"; 10 + import { Cursor } from "app/(home-pages)/reader/getReaderFeed"; 11 + 12 + export const ProfileSubscriptionsContent = (props: { 13 + did: string; 14 + subscriptions: PublicationSubscription[]; 15 + nextCursor: Cursor | null; 16 + }) => { 17 + const getKey = ( 18 + pageIndex: number, 19 + previousPageData: { 20 + subscriptions: PublicationSubscription[]; 21 + nextCursor: Cursor | null; 22 + } | null, 23 + ) => { 24 + // Reached the end 25 + if (previousPageData && !previousPageData.nextCursor) return null; 26 + 27 + // First page, we don't have previousPageData 28 + if (pageIndex === 0) 29 + return ["profile-subscriptions", props.did, null] as const; 30 + 31 + // Add the cursor to the key 32 + return [ 33 + "profile-subscriptions", 34 + props.did, 35 + previousPageData?.nextCursor, 36 + ] as const; 37 + }; 38 + 39 + const { data, size, setSize, isValidating } = useSWRInfinite( 40 + getKey, 41 + ([_, did, cursor]) => getSubscriptions(did, cursor), 42 + { 43 + fallbackData: [ 44 + { subscriptions: props.subscriptions, nextCursor: props.nextCursor }, 45 + ], 46 + revalidateFirstPage: false, 47 + }, 48 + ); 49 + 50 + const loadMoreRef = useRef<HTMLDivElement>(null); 51 + 52 + // Set up intersection observer to load more when trigger element is visible 53 + useEffect(() => { 54 + const observer = new IntersectionObserver( 55 + (entries) => { 56 + if (entries[0].isIntersecting && !isValidating) { 57 + const hasMore = data && data[data.length - 1]?.nextCursor; 58 + if (hasMore) { 59 + setSize(size + 1); 60 + } 61 + } 62 + }, 63 + { threshold: 0.1 }, 64 + ); 65 + 66 + if (loadMoreRef.current) { 67 + observer.observe(loadMoreRef.current); 68 + } 69 + 70 + return () => observer.disconnect(); 71 + }, [data, size, setSize, isValidating]); 72 + 73 + const allSubscriptions = data 74 + ? data.flatMap((page) => page.subscriptions) 75 + : []; 76 + 77 + if (allSubscriptions.length === 0 && !isValidating) { 78 + return ( 79 + <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 + ); 81 + } 82 + 83 + return ( 84 + <div className="relative"> 85 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 86 + {allSubscriptions.map((sub) => ( 87 + <PubListing key={sub.uri} {...sub} /> 88 + ))} 89 + </div> 90 + {/* Trigger element for loading more subscriptions */} 91 + <div 92 + ref={loadMoreRef} 93 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 94 + aria-hidden="true" 95 + /> 96 + {isValidating && ( 97 + <div className="text-center text-tertiary py-4"> 98 + Loading more subscriptions... 99 + </div> 100 + )} 101 + </div> 102 + ); 103 + };
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions"; 3 + import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 + 5 + export default async function ProfileSubscriptionsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + const params = await props.params; 9 + const didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + const resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { subscriptions, nextCursor } = await getSubscriptions(did); 20 + 21 + return ( 22 + <ProfileSubscriptionsContent 23 + did={did} 24 + subscriptions={subscriptions} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+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 };
+3 -4
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[]; ··· 33 32 34 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 35 34 getKey, 36 - ([_, cursor]) => getSubscriptions(cursor), 35 + ([_, cursor]) => getSubscriptions(null, cursor), 37 36 { 38 37 fallbackData: [ 39 38 { subscriptions: props.publications, nextCursor: props.nextCursor }, ··· 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 };
+1 -1
app/(home-pages)/reader/getReaderFeed.ts
··· 83 83 84 84 export type Post = { 85 85 author: string | null; 86 - publication: { 86 + publication?: { 87 87 href: string; 88 88 pubRecord: Json; 89 89 uri: string;
+13 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 8 8 import { idResolver } from "./idResolver"; 9 9 import { Cursor } from "./getReaderFeed"; 10 10 11 - export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 11 + export async function getSubscriptions( 12 + did?: string | null, 13 + cursor?: Cursor | null, 14 + ): Promise<{ 12 15 nextCursor: null | Cursor; 13 16 subscriptions: PublicationSubscription[]; 14 17 }> { 15 - let auth_res = await getIdentityData(); 16 - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 18 + // If no DID provided, use logged-in user's DID 19 + let identity = did; 20 + if (!identity) { 21 + const auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 23 + identity = auth_res.atp_did; 24 + } 25 + 17 26 let query = supabaseServerClient 18 27 .from("publication_subscriptions") 19 28 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 25 34 }) 26 35 .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 36 .limit(25) 28 - .eq("identity", auth_res.atp_did); 37 + .eq("identity", identity); 29 38 30 39 if (cursor) { 31 40 query = query.or(
-1
app/(home-pages)/reader/page.tsx
··· 12 12 return ( 13 13 <DashboardLayout 14 14 id="reader" 15 - cardBorderHidden={false} 16 15 currentPage="reader" 17 16 defaultTab="Read" 18 17 actions={null}
+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 + }
+83
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 + import { Metadata } from "next"; 7 + 8 + export async function generateMetadata(props: { 9 + params: Promise<{ tag: string }>; 10 + }): Promise<Metadata> { 11 + const params = await props.params; 12 + const decodedTag = decodeURIComponent(params.tag); 13 + return { title: `${decodedTag} - Leaflet` }; 14 + } 15 + 16 + export default async function TagPage(props: { 17 + params: Promise<{ tag: string }>; 18 + }) { 19 + const params = await props.params; 20 + const decodedTag = decodeURIComponent(params.tag); 21 + const { posts } = await getDocumentsByTag(decodedTag); 22 + 23 + return ( 24 + <DashboardLayout 25 + id="tag" 26 + currentPage="tag" 27 + defaultTab="default" 28 + actions={null} 29 + tabs={{ 30 + default: { 31 + controls: null, 32 + content: <TagContent tag={decodedTag} posts={posts} />, 33 + }, 34 + }} 35 + /> 36 + ); 37 + } 38 + 39 + const TagContent = (props: { 40 + tag: string; 41 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 42 + }) => { 43 + return ( 44 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 45 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 46 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 47 + </div> 48 + <div className="pt-6 flex flex-col gap-3"> 49 + {props.posts.length === 0 ? ( 50 + <EmptyState tag={props.tag} /> 51 + ) : ( 52 + props.posts.map((post) => ( 53 + <PostListing key={post.documents.uri} {...post} /> 54 + )) 55 + )} 56 + </div> 57 + </div> 58 + ); 59 + }; 60 + 61 + const TagHeader = (props: { tag: string; postCount: number }) => { 62 + return ( 63 + <div className="flex flex-col leading-tight items-center"> 64 + <div className="flex items-center gap-3 text-xl font-bold text-primary"> 65 + <TagTiny className="scale-150" /> 66 + <h1>{props.tag}</h1> 67 + </div> 68 + <div className="text-tertiary text-sm"> 69 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 70 + </div> 71 + </div> 72 + ); 73 + }; 74 + 75 + const EmptyState = (props: { tag: string }) => { 76 + return ( 77 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 78 + <div className="text-tertiary"> 79 + No posts found with the tag "{props.tag}" 80 + </div> 81 + </div> 82 + ); 83 + };
+1 -1
app/[leaflet_id]/actions/HelpButton.tsx
··· 161 161 className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 162 style={{ 163 163 backgroundColor: isHovered 164 - ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 164 + ? "rgb(var(--accent-light))" 165 165 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 166 }} 167 167 onMouseEnter={handleMouseEnter}
+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 });
+60 -29
app/[leaflet_id]/actions/PublishButton.tsx
··· 13 13 import { PublishSmall } from "components/Icons/PublishSmall"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { InputWithLabel } from "components/Input"; 16 - import { Menu, MenuItem } from "components/Layout"; 16 + import { Menu, MenuItem } from "components/Menu"; 17 17 import { 18 18 useLeafletDomains, 19 19 useLeafletPublicationData, ··· 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 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 42 43 43 44 export const PublishButton = (props: { entityID: string }) => { 44 45 let { data: pub } = useLeafletPublicationData(); ··· 64 65 const UpdateButton = () => { 65 66 let [isLoading, setIsLoading] = useState(false); 66 67 let { data: pub, mutate } = useLeafletPublicationData(); 67 - let { permission_token, rootEntity } = useReplicache(); 68 + let { permission_token, rootEntity, rep } = useReplicache(); 68 69 let { identity } = useIdentityData(); 69 70 let toaster = useToaster(); 70 71 72 + // Get title and description from Replicache state (same as draft editor) 73 + // This ensures we use the latest edited values, not stale cached data 74 + let replicacheTitle = useSubscribe(rep, (tx) => 75 + tx.get<string>("publication_title"), 76 + ); 77 + let replicacheDescription = useSubscribe(rep, (tx) => 78 + tx.get<string>("publication_description"), 79 + ); 80 + 81 + // Use Replicache state if available, otherwise fall back to pub data 82 + const currentTitle = 83 + typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || ""; 84 + const currentDescription = 85 + typeof replicacheDescription === "string" 86 + ? replicacheDescription 87 + : pub?.description || ""; 88 + 89 + // Get tags from Replicache state (same as draft editor) 90 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 91 + const currentTags = Array.isArray(tags) ? tags : []; 92 + 93 + // Get cover image from Replicache state 94 + let coverImage = useSubscribe(rep, (tx) => 95 + tx.get<string | null>("publication_cover_image"), 96 + ); 97 + 71 98 return ( 72 99 <ActionButton 73 100 primary ··· 76 103 onClick={async () => { 77 104 if (!pub) return; 78 105 setIsLoading(true); 79 - let doc = await publishToPublication({ 106 + let result = await publishToPublication({ 80 107 root_entity: rootEntity, 81 108 publication_uri: pub.publications?.uri, 82 109 leaflet_id: permission_token.id, 83 - title: pub.title, 84 - description: pub.description, 110 + title: currentTitle, 111 + description: currentDescription, 112 + tags: currentTags, 113 + cover_image: coverImage, 85 114 }); 86 115 setIsLoading(false); 87 116 mutate(); 88 117 118 + if (!result.success) { 119 + toaster({ 120 + content: isOAuthSessionError(result.error) ? ( 121 + <OAuthErrorMessage error={result.error} /> 122 + ) : ( 123 + "Failed to publish" 124 + ), 125 + type: "error", 126 + }); 127 + return; 128 + } 129 + 89 130 // Generate URL based on whether it's in a publication or standalone 90 131 let docUrl = pub.publications 91 - ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 92 - : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 132 + ? `${getPublicationURL(pub.publications)}/${result.rkey}` 133 + : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; 93 134 94 135 toaster({ 95 136 content: ( 96 137 <div> 97 138 {pub.doc ? "Updated! " : "Published! "} 98 - <SpeedyLink href={docUrl}>link</SpeedyLink> 139 + <SpeedyLink className="underline" href={docUrl}> 140 + See Published Post 141 + </SpeedyLink> 99 142 </div> 100 143 ), 101 144 type: "success", ··· 109 152 let { identity } = useIdentityData(); 110 153 let { permission_token } = useReplicache(); 111 154 let query = useSearchParams(); 112 - console.log(query.get("publish")); 113 155 let [open, setOpen] = useState(query.get("publish") !== null); 114 156 115 157 let isMobile = useIsMobile(); ··· 177 219 <hr className="border-border-light mt-3 mb-2" /> 178 220 179 221 <div className="flex gap-2 items-center place-self-end"> 180 - {selectedPub && selectedPub !== "create" && ( 222 + {selectedPub !== "looseleaf" && selectedPub && ( 181 223 <SaveAsDraftButton 182 224 selectedPub={selectedPub} 183 225 leafletId={permission_token.id} ··· 230 272 if (props.selectedPub === "create") return; 231 273 e.preventDefault(); 232 274 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 - 275 + await moveLeafletToPublication( 276 + props.leafletId, 277 + props.selectedPub, 278 + props.metadata, 279 + props.entitiesToDelete, 280 + ); 250 281 await Promise.all([rep?.pull(), mutate()]); 251 282 setIsLoading(false); 252 283 }}
+1 -1
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 3 3 import { getShareLink } from "./getShareLink"; 4 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Layout"; 6 + import { Menu, MenuItem } from "components/Menu"; 7 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 8 import useSWR from "swr"; 9 9 import LoginForm from "app/login/LoginForm";
+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 + };
+193 -101
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"; 25 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 23 26 24 27 type Props = { 25 28 title: string; ··· 31 34 record?: PubLeafletPublication.Record; 32 35 posts_in_pub?: number; 33 36 entitiesToDelete?: string[]; 37 + hasDraft: boolean; 34 38 }; 35 39 36 40 export function PublishPost(props: Props) { ··· 38 42 { state: "default" } | { state: "success"; post_url: string } 39 43 >({ state: "default" }); 40 44 return ( 41 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 45 + <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 46 {publishState.state === "default" ? ( 43 47 <PublishPostForm setPublishState={setPublishState} {...props} /> 44 48 ) : ( ··· 58 62 setPublishState: (s: { state: "success"; post_url: string }) => void; 59 63 } & Props, 60 64 ) => { 65 + let editorStateRef = useRef<EditorState | null>(null); 66 + let [charCount, setCharCount] = useState(0); 61 67 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 62 - let editorStateRef = useRef<EditorState | null>(null); 63 68 let [isLoading, setIsLoading] = useState(false); 64 - let [charCount, setCharCount] = useState(0); 69 + let [oauthError, setOauthError] = useState< 70 + import("src/atproto-oauth").OAuthSessionError | null 71 + >(null); 65 72 let params = useParams(); 66 73 let { rep } = useReplicache(); 67 74 75 + // For publications with drafts, use Replicache; otherwise use local state 76 + let replicacheTags = useSubscribe(rep, (tx) => 77 + tx.get<string[]>("publication_tags"), 78 + ); 79 + let [localTags, setLocalTags] = useState<string[]>([]); 80 + 81 + // Get cover image from Replicache 82 + let replicacheCoverImage = useSubscribe(rep, (tx) => 83 + tx.get<string | null>("publication_cover_image"), 84 + ); 85 + 86 + // Use Replicache tags only when we have a draft 87 + const hasDraft = props.hasDraft; 88 + const currentTags = hasDraft 89 + ? Array.isArray(replicacheTags) 90 + ? replicacheTags 91 + : [] 92 + : localTags; 93 + 94 + // Update tags via Replicache mutation or local state depending on context 95 + const handleTagsChange = async (newTags: string[]) => { 96 + if (hasDraft) { 97 + await rep?.mutate.updatePublicationDraft({ 98 + tags: newTags, 99 + }); 100 + } else { 101 + setLocalTags(newTags); 102 + } 103 + }; 104 + 68 105 async function submit() { 69 106 if (isLoading) return; 70 107 setIsLoading(true); 108 + setOauthError(null); 71 109 await rep?.push(); 72 - let doc = await publishToPublication({ 110 + let result = await publishToPublication({ 73 111 root_entity: props.root_entity, 74 112 publication_uri: props.publication_uri, 75 113 leaflet_id: props.leaflet_id, 76 114 title: props.title, 77 115 description: props.description, 116 + tags: currentTags, 117 + cover_image: replicacheCoverImage, 78 118 entitiesToDelete: props.entitiesToDelete, 79 119 }); 80 - if (!doc) return; 120 + 121 + if (!result.success) { 122 + setIsLoading(false); 123 + if (isOAuthSessionError(result.error)) { 124 + setOauthError(result.error); 125 + } 126 + return; 127 + } 81 128 82 129 // Generate post URL based on whether it's in a publication or standalone 83 130 let post_url = props.record?.base_path 84 - ? `https://${props.record.base_path}/${doc.rkey}` 85 - : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 131 + ? `https://${props.record.base_path}/${result.rkey}` 132 + : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 86 133 87 134 let [text, facets] = editorStateRef.current 88 135 ? editorStateToFacetedText(editorStateRef.current) 89 136 : []; 90 - if (shareOption === "bluesky") 91 - await publishPostToBsky({ 137 + if (shareOption === "bluesky") { 138 + let bskyResult = await publishPostToBsky({ 92 139 facets: facets || [], 93 140 text: text || "", 94 141 title: props.title, 95 142 url: post_url, 96 143 description: props.description, 97 - document_record: doc.record, 98 - rkey: doc.rkey, 144 + document_record: result.record, 145 + rkey: result.rkey, 99 146 }); 147 + if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) { 148 + setIsLoading(false); 149 + setOauthError(bskyResult.error); 150 + return; 151 + } 152 + } 100 153 setIsLoading(false); 101 154 props.setPublishState({ state: "success", post_url }); 102 155 } ··· 109 162 submit(); 110 163 }} 111 164 > 112 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 165 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 113 166 <PublishingTo 114 167 publication_uri={props.publication_uri} 115 168 record={props.record} 116 169 /> 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> 170 + <hr className="border-border" /> 171 + <ShareOptions 172 + setShareOption={setShareOption} 173 + shareOption={shareOption} 174 + charCount={charCount} 175 + setCharCount={setCharCount} 176 + editorStateRef={editorStateRef} 177 + {...props} 178 + /> 179 + <hr className="border-border " /> 180 + <div className="flex flex-col gap-2"> 181 + <h4>Tags</h4> 182 + <TagSelector 183 + selectedTags={currentTags} 184 + setSelectedTags={handleTagsChange} 185 + /> 186 + </div> 187 + <hr className="border-border mb-2" /> 188 + 189 + <div className="flex flex-col gap-2"> 190 + <div className="flex justify-between"> 191 + <Link 192 + className="hover:no-underline! font-bold" 193 + href={`/${params.leaflet_id}`} 194 + > 195 + Back 196 + </Link> 197 + <ButtonPrimary 198 + type="submit" 199 + className="place-self-end h-[30px]" 200 + disabled={charCount > 300} 201 + > 202 + {isLoading ? <DotLoader /> : "Publish this Post!"} 203 + </ButtonPrimary> 152 204 </div> 153 - </Radio> 205 + {oauthError && ( 206 + <OAuthErrorMessage 207 + error={oauthError} 208 + className="text-right text-sm text-accent-contrast" 209 + /> 210 + )} 211 + </div> 212 + </div> 213 + </form> 214 + </div> 215 + ); 216 + }; 154 217 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} 218 + const ShareOptions = (props: { 219 + shareOption: "quiet" | "bluesky"; 220 + setShareOption: (option: typeof props.shareOption) => void; 221 + charCount: number; 222 + setCharCount: (c: number) => void; 223 + editorStateRef: React.MutableRefObject<EditorState | null>; 224 + title: string; 225 + profile: ProfileViewDetailed; 226 + description: string; 227 + record?: PubLeafletPublication.Record; 228 + }) => { 229 + return ( 230 + <div className="flex flex-col gap-2"> 231 + <h4>Notifications</h4> 232 + <Radio 233 + checked={props.shareOption === "quiet"} 234 + onChange={(e) => { 235 + if (e.target === e.currentTarget) { 236 + props.setShareOption("quiet"); 237 + } 238 + }} 239 + name="share-options" 240 + id="share-quietly" 241 + value="Share Quietly" 242 + > 243 + <div className="flex flex-col"> 244 + <div className="font-bold">Share Quietly</div> 245 + <div className="text-sm text-tertiary font-normal"> 246 + No one will be notified about this post 247 + </div> 248 + </div> 249 + </Radio> 250 + <Radio 251 + checked={props.shareOption === "bluesky"} 252 + onChange={(e) => { 253 + if (e.target === e.currentTarget) { 254 + props.setShareOption("bluesky"); 255 + } 256 + }} 257 + name="share-options" 258 + id="share-bsky" 259 + value="Share on Bluesky" 260 + > 261 + <div className="flex flex-col"> 262 + <div className="font-bold">Share on Bluesky</div> 263 + <div className="text-sm text-tertiary font-normal"> 264 + Pub subscribers will be updated via a custom Bluesky feed 265 + </div> 266 + </div> 267 + </Radio> 268 + <div 269 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 270 + > 271 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 272 + <div className="flex gap-2"> 273 + <img 274 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 275 + src={props.profile.avatar} 276 + /> 277 + <div className="flex flex-col w-full"> 278 + <div className="flex gap-2 "> 279 + <p className="font-bold">{props.profile.displayName}</p> 280 + <p className="text-tertiary">@{props.profile.handle}</p> 281 + </div> 282 + <div className="flex flex-col"> 283 + <BlueskyPostEditorProsemirror 284 + editorStateRef={props.editorStateRef} 285 + onCharCountChange={props.setCharCount} 163 286 /> 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> 287 + </div> 288 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 289 + <div className="flex flex-col p-2"> 290 + <div className="font-bold">{props.title}</div> 291 + <div className="text-tertiary">{props.description}</div> 292 + <hr className="border-border mt-2 mb-1" /> 293 + <p className="text-xs text-tertiary"> 294 + {props.record?.base_path} 295 + </p> 192 296 </div> 193 297 </div> 298 + <div className="text-xs text-secondary italic place-self-end pt-2"> 299 + {props.charCount}/300 300 + </div> 194 301 </div> 195 302 </div> 196 - <div className="flex justify-between"> 197 - <Link 198 - className="hover:no-underline! font-bold" 199 - href={`/${params.leaflet_id}`} 200 - > 201 - Back 202 - </Link> 203 - <ButtonPrimary 204 - type="submit" 205 - className="place-self-end h-[30px]" 206 - disabled={charCount > 300} 207 - > 208 - {isLoading ? <DotLoader /> : "Publish this Post!"} 209 - </ButtonPrimary> 210 - </div> 211 303 </div> 212 - </form> 304 + </div> 213 305 </div> 214 306 ); 215 307 };
+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 );
+56 -16
app/[leaflet_id]/publish/publishBskyPost.ts
··· 9 9 import { TID } from "@atproto/common"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 11 import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 - import { createOauthClient } from "src/atproto-oauth"; 12 + import { 13 + restoreOAuthSession, 14 + OAuthSessionError, 15 + } from "src/atproto-oauth"; 13 16 import { supabaseServerClient } from "supabase/serverClient"; 14 17 import { Json } from "supabase/database.types"; 15 18 import { 16 19 getMicroLinkOgImage, 17 20 getWebpageImage, 18 21 } from "src/utils/getMicroLinkOgImage"; 22 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 23 + 24 + type PublishBskyResult = 25 + | { success: true } 26 + | { success: false; error: OAuthSessionError }; 19 27 20 28 export async function publishPostToBsky(args: { 21 29 text: string; ··· 25 33 document_record: PubLeafletDocument.Record; 26 34 rkey: string; 27 35 facets: AppBskyRichtextFacet.Main[]; 28 - }) { 29 - const oauthClient = await createOauthClient(); 36 + }): Promise<PublishBskyResult> { 30 37 let identity = await getIdentityData(); 31 - if (!identity || !identity.atp_did) return null; 38 + if (!identity || !identity.atp_did) { 39 + return { 40 + success: false, 41 + error: { 42 + type: "oauth_session_expired", 43 + message: "Not authenticated", 44 + did: "", 45 + }, 46 + }; 47 + } 32 48 33 - let credentialSession = await oauthClient.restore(identity.atp_did); 49 + const sessionResult = await restoreOAuthSession(identity.atp_did); 50 + if (!sessionResult.ok) { 51 + return { success: false, error: sessionResult.error }; 52 + } 53 + let credentialSession = sessionResult.value; 34 54 let agent = new AtpBaseClient( 35 55 credentialSession.fetchHandler.bind(credentialSession), 36 56 ); 37 - let newPostUrl = args.url; 38 - let preview_image = await getWebpageImage(newPostUrl, { 39 - width: 1400, 40 - height: 733, 41 - noCache: true, 42 - }); 43 57 44 - let binary = await preview_image.blob(); 45 - let resized_preview_image = await sharp(await binary.arrayBuffer()) 58 + // Get image binary - prefer cover image, fall back to screenshot 59 + let imageBinary: Blob | null = null; 60 + 61 + if (args.document_record.coverImage) { 62 + let cid = 63 + (args.document_record.coverImage.ref as unknown as { $link: string })[ 64 + "$link" 65 + ] || args.document_record.coverImage.ref.toString(); 66 + 67 + let coverImageResponse = await fetchAtprotoBlob(identity.atp_did, cid); 68 + if (coverImageResponse) { 69 + imageBinary = await coverImageResponse.blob(); 70 + } 71 + } 72 + 73 + // Fall back to screenshot if no cover image or fetch failed 74 + if (!imageBinary) { 75 + let preview_image = await getWebpageImage(args.url, { 76 + width: 1400, 77 + height: 733, 78 + noCache: true, 79 + }); 80 + imageBinary = await preview_image.blob(); 81 + } 82 + 83 + // Resize and upload 84 + let resizedImage = await sharp(await imageBinary.arrayBuffer()) 46 85 .resize({ 47 86 width: 1200, 87 + height: 630, 48 88 fit: "cover", 49 89 }) 50 90 .webp({ quality: 85 }) 51 91 .toBuffer(); 52 92 53 - let blob = await agent.com.atproto.repo.uploadBlob(resized_preview_image, { 54 - headers: { "Content-Type": binary.type }, 93 + let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, { 94 + headers: { "Content-Type": "image/webp" }, 55 95 }); 56 96 let bsky = new BskyAgent(credentialSession); 57 97 let post = await bsky.app.bsky.feed.post.create( ··· 90 130 data: record as Json, 91 131 }) 92 132 .eq("uri", result.uri); 93 - return true; 133 + return { success: true }; 94 134 }
+29 -11
app/api/atproto_images/route.ts
··· 1 1 import { IdResolver } from "@atproto/identity"; 2 2 import { NextRequest, NextResponse } from "next/server"; 3 + 3 4 let idResolver = new IdResolver(); 4 5 5 - export async function GET(req: NextRequest) { 6 - const url = new URL(req.url); 7 - const params = { 8 - did: url.searchParams.get("did") ?? "", 9 - cid: url.searchParams.get("cid") ?? "", 10 - }; 11 - if (!params.did || !params.cid) 12 - return new NextResponse(null, { status: 404 }); 6 + /** 7 + * Fetches a blob from an AT Protocol PDS given a DID and CID 8 + * Returns the Response object or null if the blob couldn't be fetched 9 + */ 10 + export async function fetchAtprotoBlob( 11 + did: string, 12 + cid: string, 13 + ): Promise<Response | null> { 14 + if (!did || !cid) return null; 13 15 14 - let identity = await idResolver.did.resolve(params.did); 16 + let identity = await idResolver.did.resolve(did); 15 17 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 16 - if (!service) return new NextResponse(null, { status: 404 }); 18 + if (!service) return null; 19 + 17 20 const response = await fetch( 18 - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`, 21 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 19 22 { 20 23 headers: { 21 24 "Accept-Encoding": "gzip, deflate, br, zstd", 22 25 }, 23 26 }, 24 27 ); 28 + 29 + if (!response.ok) return null; 30 + 31 + return response; 32 + } 33 + 34 + export async function GET(req: NextRequest) { 35 + const url = new URL(req.url); 36 + const params = { 37 + did: url.searchParams.get("did") ?? "", 38 + cid: url.searchParams.get("cid") ?? "", 39 + }; 40 + 41 + const response = await fetchAtprotoBlob(params.did, params.cid); 42 + if (!response) return new NextResponse(null, { status: 404 }); 25 43 26 44 // Clone the response to modify headers 27 45 const cachedResponse = new Response(response.body, response);
+41
app/api/bsky/agent.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { cookies } from "next/headers"; 3 + import { createOauthClient } from "src/atproto-oauth"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function getAuthenticatedAgent(): Promise<Agent | null> { 7 + try { 8 + const cookieStore = await cookies(); 9 + const authToken = 10 + cookieStore.get("auth_token")?.value || 11 + cookieStore.get("external_auth_token")?.value; 12 + 13 + if (!authToken || authToken === "null") return null; 14 + 15 + const { data } = await supabaseServerClient 16 + .from("email_auth_tokens") 17 + .select("identities(atp_did)") 18 + .eq("id", authToken) 19 + .eq("confirmed", true) 20 + .single(); 21 + 22 + const did = data?.identities?.atp_did; 23 + if (!did) return null; 24 + 25 + const oauthClient = await createOauthClient(); 26 + const session = await oauthClient.restore(did); 27 + return new Agent(session); 28 + } catch (error) { 29 + console.error("Failed to get authenticated agent:", error); 30 + return null; 31 + } 32 + } 33 + 34 + export async function getAgent(): Promise<Agent> { 35 + const agent = await getAuthenticatedAgent(); 36 + if (agent) return agent; 37 + 38 + return new Agent({ 39 + service: "https://public.api.bsky.app", 40 + }); 41 + }
+41
app/api/bsky/quotes/route.ts
··· 1 + import { lexToJson } from "@atproto/api"; 2 + import { NextRequest } from "next/server"; 3 + import { getAgent } from "../agent"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + export async function GET(req: NextRequest) { 8 + try { 9 + const searchParams = req.nextUrl.searchParams; 10 + const uri = searchParams.get("uri"); 11 + const cursor = searchParams.get("cursor"); 12 + const limit = searchParams.get("limit"); 13 + 14 + if (!uri) { 15 + return Response.json( 16 + { error: "uri parameter is required" }, 17 + { status: 400 }, 18 + ); 19 + } 20 + 21 + const agent = await getAgent(); 22 + 23 + const response = await agent.app.bsky.feed.getQuotes({ 24 + uri, 25 + limit: limit ? parseInt(limit, 10) : 50, 26 + cursor: cursor || undefined, 27 + }); 28 + 29 + const result = lexToJson(response.data); 30 + 31 + return Response.json(result, { 32 + headers: { 33 + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating 34 + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", 35 + }, 36 + }); 37 + } catch (error) { 38 + console.error("Error fetching Bluesky quotes:", error); 39 + return Response.json({ error: "Failed to fetch quotes" }, { status: 500 }); 40 + } 41 + }
+41
app/api/bsky/thread/route.ts
··· 1 + import { lexToJson } from "@atproto/api"; 2 + import { NextRequest } from "next/server"; 3 + import { getAgent } from "../agent"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + export async function GET(req: NextRequest) { 8 + try { 9 + const searchParams = req.nextUrl.searchParams; 10 + const uri = searchParams.get("uri"); 11 + const depth = searchParams.get("depth"); 12 + const parentHeight = searchParams.get("parentHeight"); 13 + 14 + if (!uri) { 15 + return Response.json( 16 + { error: "uri parameter is required" }, 17 + { status: 400 }, 18 + ); 19 + } 20 + 21 + const agent = await getAgent(); 22 + 23 + const response = await agent.getPostThread({ 24 + uri, 25 + depth: depth ? parseInt(depth, 10) : 6, 26 + parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80, 27 + }); 28 + 29 + const thread = lexToJson(response.data.thread); 30 + 31 + return Response.json(thread, { 32 + headers: { 33 + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating 34 + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", 35 + }, 36 + }); 37 + } catch (error) { 38 + console.error("Error fetching Bluesky thread:", error); 39 + return Response.json({ error: "Failed to fetch thread" }, { status: 500 }); 40 + } 41 + }
+5 -7
app/api/inngest/functions/index_follows.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtpAgent, AtUri } from "@atproto/api"; 3 - import { createIdentity } from "actions/createIdentity"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 3 import { inngest } from "../client"; 6 - import { pool } from "supabase/pool"; 7 4 8 5 export const index_follows = inngest.createFunction( 9 6 { ··· 58 55 .eq("atp_did", event.data.did) 59 56 .single(); 60 57 if (!exists) { 61 - const client = await pool.connect(); 62 - let db = drizzle(client); 63 - let identity = await createIdentity(db, { atp_did: event.data.did }); 64 - client.release(); 58 + const { data: identity } = await supabaseServerClient 59 + .from("identities") 60 + .insert({ atp_did: event.data.did }) 61 + .select() 62 + .single(); 65 63 return identity; 66 64 } 67 65 }),
+8 -9
app/api/oauth/[route]/route.ts
··· 1 - import { createIdentity } from "actions/createIdentity"; 2 1 import { subscribeToPublication } from "app/lish/subscribeToPublication"; 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 2 import { cookies } from "next/headers"; 5 3 import { redirect } from "next/navigation"; 6 4 import { NextRequest, NextResponse } from "next/server"; ··· 13 11 ActionAfterSignIn, 14 12 parseActionFromSearchParam, 15 13 } from "./afterSignInActions"; 16 - import { pool } from "supabase/pool"; 17 14 18 15 type OauthRequestClientState = { 19 16 redirect: string | null; ··· 80 77 81 78 return handleAction(s.action, redirectPath); 82 79 } 83 - const client = await pool.connect(); 84 - const db = drizzle(client); 85 - identity = await createIdentity(db, { atp_did: session.did }); 86 - client.release(); 80 + const { data } = await supabaseServerClient 81 + .from("identities") 82 + .insert({ atp_did: session.did }) 83 + .select() 84 + .single(); 85 + identity = data; 87 86 } 88 87 let { data: token } = await supabaseServerClient 89 88 .from("email_auth_tokens") 90 89 .insert({ 91 - identity: identity.id, 90 + identity: identity!.id, 92 91 confirmed: true, 93 92 confirmation_code: "", 94 93 }) ··· 121 120 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 122 121 if (action?.action === "subscribe") { 123 122 let result = await subscribeToPublication(action.publication); 124 - if (result.hasFeed === false) 123 + if (result.success && result.hasFeed === false) 125 124 url.searchParams.set("showSubscribeSuccess", "true"); 126 125 } 127 126
+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({
+69
app/api/rpc/[command]/get_profile_data.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Agent } from "@atproto/api"; 7 + import { getIdentityData } from "actions/getIdentityData"; 8 + import { createOauthClient } from "src/atproto-oauth"; 9 + 10 + export type GetProfileDataReturnType = Awaited< 11 + ReturnType<(typeof get_profile_data)["handler"]> 12 + >; 13 + 14 + export const get_profile_data = makeRoute({ 15 + route: "get_profile_data", 16 + input: z.object({ 17 + didOrHandle: z.string(), 18 + }), 19 + handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => { 20 + // Resolve handle to DID if necessary 21 + let did = didOrHandle; 22 + 23 + if (!didOrHandle.startsWith("did:")) { 24 + const resolved = await idResolver.handle.resolve(didOrHandle); 25 + if (!resolved) { 26 + throw new Error("Could not resolve handle to DID"); 27 + } 28 + did = resolved; 29 + } 30 + let agent; 31 + let authed_identity = await getIdentityData(); 32 + if (authed_identity?.atp_did) { 33 + try { 34 + const oauthClient = await createOauthClient(); 35 + let credentialSession = await oauthClient.restore( 36 + authed_identity.atp_did, 37 + ); 38 + agent = new Agent(credentialSession); 39 + } catch (e) { 40 + agent = new Agent({ 41 + service: "https://public.api.bsky.app", 42 + }); 43 + } 44 + } else { 45 + agent = new Agent({ 46 + service: "https://public.api.bsky.app", 47 + }); 48 + } 49 + 50 + let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); 51 + 52 + let publicationsReq = supabase 53 + .from("publications") 54 + .select("*") 55 + .eq("identity_did", did); 56 + 57 + let [{ data: profile }, { data: publications }] = await Promise.all([ 58 + profileReq, 59 + publicationsReq, 60 + ]); 61 + 62 + return { 63 + result: { 64 + profile, 65 + publications: publications || [], 66 + }, 67 + }; 68 + }, 69 + });
+12
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[]; 77 + cover_image: string | null; 76 78 }[]; 77 79 let pub_patch = publication_data?.[0] 78 80 ? [ ··· 85 87 op: "put", 86 88 key: "publication_title", 87 89 value: publication_data[0].title, 90 + }, 91 + { 92 + op: "put", 93 + key: "publication_tags", 94 + value: publication_data[0].tags || [], 95 + }, 96 + { 97 + op: "put", 98 + key: "publication_cover_image", 99 + value: publication_data[0].cover_image || null, 88 100 }, 89 101 ] 90 102 : [];
+6
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"; 16 + import { get_profile_data } from "./get_profile_data"; 14 17 15 18 let supabase = createClient<Database>( 16 19 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 38 get_leaflet_subdomain_status, 36 39 get_leaflet_data, 37 40 get_publication_data, 41 + search_publication_names, 42 + search_publication_documents, 43 + get_profile_data, 38 44 ]; 39 45 export async function POST( 40 46 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 + });
+215
app/api/unstable_validate/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtpAgent, AtUri } from "@atproto/api"; 3 + import { DidResolver } from "@atproto/identity"; 4 + import { 5 + PubLeafletDocument, 6 + PubLeafletPublication, 7 + PubLeafletPagesLinearDocument, 8 + PubLeafletPagesCanvas, 9 + } from "lexicons/api"; 10 + 11 + const didResolver = new DidResolver({}); 12 + 13 + export async function GET(request: NextRequest) { 14 + try { 15 + const atUriString = request.nextUrl.searchParams.get("uri"); 16 + 17 + if (!atUriString) { 18 + return NextResponse.json( 19 + { 20 + success: false, 21 + error: "Missing uri parameter", 22 + }, 23 + { status: 400 }, 24 + ); 25 + } 26 + 27 + const uri = new AtUri(atUriString); 28 + 29 + // Only allow document and publication collections 30 + if ( 31 + uri.collection !== "pub.leaflet.document" && 32 + uri.collection !== "pub.leaflet.publication" 33 + ) { 34 + return NextResponse.json( 35 + { 36 + success: false, 37 + error: 38 + "Unsupported collection type. Must be pub.leaflet.document or pub.leaflet.publication", 39 + }, 40 + { status: 400 }, 41 + ); 42 + } 43 + 44 + // Resolve DID to get service endpoint 45 + const did = await didResolver.resolve(uri.host); 46 + const service = did?.service?.[0]; 47 + 48 + if (!service) { 49 + return NextResponse.json( 50 + { 51 + success: false, 52 + error: "Could not resolve DID service endpoint", 53 + }, 54 + { status: 404 }, 55 + ); 56 + } 57 + 58 + // Fetch the record from AT Protocol 59 + const agent = new AtpAgent({ service: service.serviceEndpoint as string }); 60 + 61 + let recordResponse; 62 + try { 63 + recordResponse = await agent.com.atproto.repo.getRecord({ 64 + repo: uri.host, 65 + collection: uri.collection, 66 + rkey: uri.rkey, 67 + }); 68 + } catch (e) { 69 + return NextResponse.json( 70 + { 71 + success: false, 72 + error: "Record not found", 73 + }, 74 + { status: 404 }, 75 + ); 76 + } 77 + 78 + // Validate based on collection type 79 + if (uri.collection === "pub.leaflet.document") { 80 + const result = PubLeafletDocument.validateRecord( 81 + recordResponse.data.value, 82 + ); 83 + if (result.success) { 84 + return NextResponse.json({ 85 + success: true, 86 + collection: uri.collection, 87 + record: result.value, 88 + }); 89 + } else { 90 + // Detailed validation: validate pages and blocks individually 91 + const record = recordResponse.data.value as { 92 + pages?: Array<{ $type?: string; blocks?: Array<{ block?: unknown }> }>; 93 + }; 94 + const pageErrors: Array<{ 95 + pageIndex: number; 96 + pageType: string; 97 + error?: unknown; 98 + blockErrors?: Array<{ 99 + blockIndex: number; 100 + blockType: string; 101 + error: unknown; 102 + block: unknown; 103 + }>; 104 + }> = []; 105 + 106 + if (record.pages && Array.isArray(record.pages)) { 107 + for (let pageIndex = 0; pageIndex < record.pages.length; pageIndex++) { 108 + const page = record.pages[pageIndex]; 109 + const pageType = page?.$type || "unknown"; 110 + 111 + // Validate page based on type 112 + let pageResult; 113 + if (pageType === "pub.leaflet.pages.linearDocument") { 114 + pageResult = PubLeafletPagesLinearDocument.validateMain(page); 115 + } else if (pageType === "pub.leaflet.pages.canvas") { 116 + pageResult = PubLeafletPagesCanvas.validateMain(page); 117 + } else { 118 + pageErrors.push({ 119 + pageIndex, 120 + pageType, 121 + error: `Unknown page type: ${pageType}`, 122 + }); 123 + continue; 124 + } 125 + 126 + if (!pageResult.success) { 127 + // Page has errors, validate individual blocks 128 + const blockErrors: Array<{ 129 + blockIndex: number; 130 + blockType: string; 131 + error: unknown; 132 + block: unknown; 133 + }> = []; 134 + 135 + if (page.blocks && Array.isArray(page.blocks)) { 136 + for ( 137 + let blockIndex = 0; 138 + blockIndex < page.blocks.length; 139 + blockIndex++ 140 + ) { 141 + const blockWrapper = page.blocks[blockIndex]; 142 + const blockType = 143 + (blockWrapper?.block as { $type?: string })?.$type || 144 + "unknown"; 145 + 146 + // Validate block wrapper based on page type 147 + let blockResult; 148 + if (pageType === "pub.leaflet.pages.linearDocument") { 149 + blockResult = 150 + PubLeafletPagesLinearDocument.validateBlock(blockWrapper); 151 + } else { 152 + blockResult = 153 + PubLeafletPagesCanvas.validateBlock(blockWrapper); 154 + } 155 + 156 + if (!blockResult.success) { 157 + blockErrors.push({ 158 + blockIndex, 159 + blockType, 160 + error: blockResult.error, 161 + block: blockWrapper, 162 + }); 163 + } 164 + } 165 + } 166 + 167 + pageErrors.push({ 168 + pageIndex, 169 + pageType, 170 + error: pageResult.error, 171 + blockErrors: blockErrors.length > 0 ? blockErrors : undefined, 172 + }); 173 + } 174 + } 175 + } 176 + 177 + return NextResponse.json({ 178 + success: false, 179 + collection: uri.collection, 180 + error: result.error, 181 + pageErrors: pageErrors.length > 0 ? pageErrors : undefined, 182 + record: recordResponse.data.value, 183 + }); 184 + } 185 + } 186 + 187 + if (uri.collection === "pub.leaflet.publication") { 188 + const result = PubLeafletPublication.validateRecord( 189 + recordResponse.data.value, 190 + ); 191 + if (result.success) { 192 + return NextResponse.json({ 193 + success: true, 194 + collection: uri.collection, 195 + record: result.value, 196 + }); 197 + } else { 198 + return NextResponse.json({ 199 + success: false, 200 + collection: uri.collection, 201 + error: result.error, 202 + }); 203 + } 204 + } 205 + } catch (error) { 206 + console.error("Error validating AT URI:", error); 207 + return NextResponse.json( 208 + { 209 + success: false, 210 + error: "Invalid URI or internal error", 211 + }, 212 + { status: 400 }, 213 + ); 214 + } 215 + }
+49 -13
app/globals.css
··· 107 107 --highlight-3: 255, 205, 195; 108 108 109 109 --list-marker-width: 36px; 110 - --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); 111 - --page-width-units: min(624px, calc(100vw - 12px)); 110 + --page-width-unitless: min( 111 + var(--page-width-setting), 112 + calc(var(--leaflet-width-unitless) - 12) 113 + ); 114 + --page-width-units: min( 115 + calc(var(--page-width-unitless) * 1px), 116 + calc(100vw - 12px) 117 + ); 112 118 113 119 --gripperSVG: url("/gripperPattern.svg"); 114 120 --gripperSVG2: url("/gripperPattern2.svg"); ··· 125 131 126 132 @media (min-width: 640px) { 127 133 :root { 134 + /*picks between max width and screen width with 64px of padding*/ 128 135 --page-width-unitless: min( 129 - 624, 136 + var(--page-width-setting), 130 137 calc(var(--leaflet-width-unitless) - 128) 131 138 ); 132 - --page-width-units: min(624px, calc(100vw - 128px)); 133 - } 134 - } 135 - 136 - @media (min-width: 1280px) { 137 - :root { 138 - --page-width-unitless: min( 139 - 624, 140 - calc((var(--leaflet-width-unitless) / 2) - 32) 139 + --page-width-units: min( 140 + calc(var(--page-width-unitless) * 1px), 141 + calc(100vw - 128px) 141 142 ); 142 - --page-width-units: min(624px, calc((100vw / 2) - 32px)); 143 143 } 144 144 } 145 145 ··· 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 } ··· 265 270 } 266 271 267 272 pre.shiki { 273 + @apply sm:p-3; 268 274 @apply p-2; 269 275 @apply rounded-md; 270 276 @apply overflow-auto; 277 + 278 + @media (min-width: 640px) { 279 + @apply p-3; 280 + } 271 281 } 272 282 273 283 .highlight:has(+ .highlight) { ··· 293 303 294 304 .ProseMirror:focus-within .selection-highlight { 295 305 background-color: transparent; 306 + } 307 + 308 + .ProseMirror .atMention.ProseMirror-selectednode, 309 + .ProseMirror .didMention.ProseMirror-selectednode { 310 + @apply text-accent-contrast; 311 + @apply px-0.5; 312 + @apply -mx-[3px]; /* extra px to account for the border*/ 313 + @apply -my-px; /*to account for the border*/ 314 + @apply rounded-[4px]; 315 + @apply box-decoration-clone; 316 + background-color: rgba(var(--accent-contrast), 0.2); 317 + border: 1px solid rgba(var(--accent-contrast), 1); 318 + } 319 + 320 + .mention { 321 + @apply cursor-pointer; 322 + @apply text-accent-contrast; 323 + @apply px-0.5; 324 + @apply -mx-[3px]; 325 + @apply -my-px; /*to account for the border*/ 326 + @apply rounded-[4px]; 327 + @apply box-decoration-clone; 328 + background-color: rgba(var(--accent-contrast), 0.2); 329 + border: 1px solid transparent; 296 330 } 297 331 298 332 .multiselected:focus-within .selection-highlight { ··· 414 448 outline: none !important; 415 449 cursor: pointer; 416 450 background-color: transparent; 451 + display: flex; 452 + gap: 0.5rem; 417 453 418 454 :hover { 419 455 text-decoration: none !important;
+55 -208
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 - }; 26 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 182 27 183 28 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 29 pubName: string; 186 30 pub_uri: string; 187 31 base_url: string; ··· 208 52 } 209 53 return ( 210 54 <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 55 <div className="flex flex-row gap-2 place-self-center"> 217 56 <BlueskySubscribeButton 218 57 pub_uri={props.pub_uri} ··· 231 70 ); 232 71 }; 233 72 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 73 + export const ManageSubscription = (props: { 237 74 pub_uri: string; 238 75 subscribers: { identity: string }[]; 239 76 base_url: string; ··· 248 85 }); 249 86 }, null); 250 87 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 88 + <Popover 89 + trigger={ 90 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 91 + } 253 92 > 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> 93 + <div className="max-w-sm flex flex-col gap-1"> 94 + <h4>Update Options</h4> 267 95 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 - 96 + {!hasFeed && ( 280 97 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 98 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 283 99 target="_blank" 284 - aria-label="Subscribe to RSS" 100 + className=" place-self-center" 285 101 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 102 + <ButtonPrimary fullWidth compact className="!px-4"> 103 + View Bluesky Custom Feed 288 104 </ButtonPrimary> 289 105 </a> 106 + )} 290 107 291 - <hr className="border-border-light my-1" /> 108 + <a 109 + href={`${props.base_url}/rss`} 110 + className="flex" 111 + target="_blank" 112 + aria-label="Subscribe to RSS" 113 + > 114 + <ButtonPrimary fullWidth compact> 115 + Get RSS 116 + </ButtonPrimary> 117 + </a> 292 118 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> 119 + <hr className="border-border-light my-1" /> 120 + 121 + <form action={unsubscribe}> 122 + <button className="font-bold text-accent-contrast w-max place-self-center"> 123 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 124 + </button> 125 + </form> 126 + </div> 127 + </Popover> 301 128 ); 302 129 }; 303 130 ··· 307 134 }) => { 308 135 let { identity } = useIdentityData(); 309 136 let toaster = useToaster(); 137 + let [oauthError, setOauthError] = useState< 138 + import("src/atproto-oauth").OAuthSessionError | null 139 + >(null); 310 140 let [, subscribe, subscribePending] = useActionState(async () => { 141 + setOauthError(null); 311 142 let result = await subscribeToPublication( 312 143 props.pub_uri, 313 144 window.location.href + "?refreshAuth", 314 145 ); 146 + if (!result.success) { 147 + if (isOAuthSessionError(result.error)) { 148 + setOauthError(result.error); 149 + } 150 + return; 151 + } 315 152 if (result.hasFeed === false) { 316 153 props.setSuccessModalOpen(true); 317 154 } ··· 346 183 } 347 184 348 185 return ( 349 - <> 186 + <div className="flex flex-col gap-2 place-self-center"> 350 187 <form 351 188 action={subscribe} 352 189 className="place-self-center flex flex-row gap-1" ··· 361 198 )} 362 199 </ButtonPrimary> 363 200 </form> 364 - </> 201 + {oauthError && ( 202 + <OAuthErrorMessage 203 + error={oauthError} 204 + className="text-center text-sm text-accent-1" 205 + /> 206 + )} 207 + </div> 365 208 ); 366 209 }; 367 210 ··· 430 273 </Dialog.Root> 431 274 ); 432 275 }; 276 + 277 + export const SubscribeOnPost = () => { 278 + return <div></div>; 279 + };
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
··· 1 + "use client"; 2 + import { ProfilePopover } from "components/ProfilePopover"; 3 + 4 + export const PublicationAuthor = (props: { 5 + did: string; 6 + displayName?: string; 7 + handle: string; 8 + }) => { 9 + return ( 10 + <p className="italic text-tertiary sm:text-base text-sm"> 11 + <ProfilePopover 12 + didOrHandle={props.did} 13 + trigger={ 14 + <span className="hover:underline"> 15 + <strong>by {props.displayName}</strong> @{props.handle} 16 + </span> 17 + } 18 + /> 19 + </p> 20 + ); 21 + };
+21 -171
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 - import { PubLeafletRichtextFacet } from "lexicons/api"; 3 - 4 - type Facet = PubLeafletRichtextFacet.Main; 5 - export function BaseTextBlock(props: { 6 - plaintext: string; 7 - facets?: Facet[]; 8 - index: number[]; 9 - preview?: boolean; 10 - }) { 11 - let children = []; 12 - let richText = new RichText({ 13 - text: props.plaintext, 14 - facets: props.facets || [], 15 - }); 16 - let counter = 0; 17 - for (const segment of richText.segments()) { 18 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 19 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 20 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 21 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 22 - let isStrikethrough = segment.facet?.find( 23 - PubLeafletRichtextFacet.isStrikethrough, 24 - ); 25 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 27 - let isHighlighted = segment.facet?.find( 28 - PubLeafletRichtextFacet.isHighlight, 29 - ); 30 - let className = ` 31 - ${isCode ? "inline-code" : ""} 32 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 33 - ${isBold ? "font-bold" : ""} 34 - ${isItalic ? "italic" : ""} 35 - ${isUnderline ? "underline" : ""} 36 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 37 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 38 - 39 - // Split text by newlines and insert <br> tags 40 - const textParts = segment.text.split('\n'); 41 - const renderedText = textParts.flatMap((part, i) => 42 - i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part] 43 - ); 44 - 45 - if (isCode) { 46 - children.push( 47 - <code key={counter} className={className} id={id?.id}> 48 - {renderedText} 49 - </code>, 50 - ); 51 - } else if (link) { 52 - children.push( 53 - <a 54 - key={counter} 55 - href={link.uri} 56 - className={`text-accent-contrast hover:underline ${className}`} 57 - target="_blank" 58 - > 59 - {renderedText} 60 - </a>, 61 - ); 62 - } else { 63 - children.push( 64 - <span key={counter} className={className} id={id?.id}> 65 - {renderedText} 66 - </span>, 67 - ); 68 - } 69 - 70 - counter++; 71 - } 72 - return <>{children}</>; 73 - } 74 - 75 - type RichTextSegment = { 76 - text: string; 77 - facet?: Exclude<Facet["features"], { $type: string }>; 78 - }; 79 - 80 - export class RichText { 81 - unicodeText: UnicodeString; 82 - facets?: Facet[]; 83 - 84 - constructor(props: { text: string; facets: Facet[] }) { 85 - this.unicodeText = new UnicodeString(props.text); 86 - this.facets = props.facets; 87 - if (this.facets) { 88 - this.facets = this.facets 89 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 90 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 91 - } 92 - } 1 + import { ProfilePopover } from "components/ProfilePopover"; 2 + import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore"; 3 + import { ReactNode } from "react"; 93 4 94 - *segments(): Generator<RichTextSegment, void, void> { 95 - const facets = this.facets || []; 96 - if (!facets.length) { 97 - yield { text: this.unicodeText.utf16 }; 98 - return; 99 - } 5 + // Re-export RichText for backwards compatibility 6 + export { RichText }; 100 7 101 - let textCursor = 0; 102 - let facetCursor = 0; 103 - do { 104 - const currFacet = facets[facetCursor]; 105 - if (textCursor < currFacet.index.byteStart) { 106 - yield { 107 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 108 - }; 109 - } else if (textCursor > currFacet.index.byteStart) { 110 - facetCursor++; 111 - continue; 112 - } 113 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 114 - const subtext = this.unicodeText.slice( 115 - currFacet.index.byteStart, 116 - currFacet.index.byteEnd, 117 - ); 118 - if (!subtext.trim()) { 119 - // dont empty string entities 120 - yield { text: subtext }; 121 - } else { 122 - yield { text: subtext, facet: currFacet.features }; 123 - } 124 - } 125 - textCursor = currFacet.index.byteEnd; 126 - facetCursor++; 127 - } while (facetCursor < facets.length); 128 - if (textCursor < this.unicodeText.length) { 129 - yield { 130 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 131 - }; 132 - } 133 - } 8 + function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 9 + return ( 10 + <ProfilePopover 11 + didOrHandle={props.did} 12 + trigger={props.children} 13 + /> 14 + ); 134 15 } 135 - function addFacet(facets: Facet[], newFacet: Facet, length: number) { 136 - if (facets.length === 0) { 137 - return [newFacet]; 138 - } 139 16 140 - const allFacets = [...facets, newFacet]; 141 - 142 - // Collect all boundary positions 143 - const boundaries = new Set<number>(); 144 - boundaries.add(0); 145 - boundaries.add(length); 146 - 147 - for (const facet of allFacets) { 148 - boundaries.add(facet.index.byteStart); 149 - boundaries.add(facet.index.byteEnd); 150 - } 151 - 152 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 153 - const result: Facet[] = []; 154 - 155 - // Process segments between consecutive boundaries 156 - for (let i = 0; i < sortedBoundaries.length - 1; i++) { 157 - const start = sortedBoundaries[i]; 158 - const end = sortedBoundaries[i + 1]; 159 - 160 - // Find facets that are active at the start position 161 - const activeFacets = allFacets.filter( 162 - (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 163 - ); 164 - 165 - // Only create facet if there are active facets (features present) 166 - if (activeFacets.length > 0) { 167 - const features = activeFacets.flatMap((f) => f.features); 168 - result.push({ 169 - index: { byteStart: start, byteEnd: end }, 170 - features, 171 - }); 172 - } 173 - } 174 - 175 - return result; 17 + export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 18 + return ( 19 + <TextBlockCore 20 + {...props} 21 + renderers={{ 22 + DidMention: DidMentionWithPopover, 23 + }} 24 + /> 25 + ); 176 26 }
+105
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import useSWR from "swr"; 4 + import { PageWrapper } from "components/Pages/Page"; 5 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 6 + import { DotLoader } from "components/utils/DotLoader"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { openPage } from "./PostPages"; 9 + import { BskyPostContent } from "./BskyPostContent"; 10 + import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 11 + 12 + // Re-export for backwards compatibility 13 + export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; 14 + 15 + type PostView = AppBskyFeedDefs.PostView; 16 + 17 + export function BlueskyQuotesPage(props: { 18 + postUri: string; 19 + pageId: string; 20 + pageOptions?: React.ReactNode; 21 + hasPageBackground: boolean; 22 + }) { 23 + const { postUri, pageId, pageOptions } = props; 24 + const drawer = useDrawerOpen(postUri); 25 + 26 + const { 27 + data: quotesData, 28 + isLoading, 29 + error, 30 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 31 + 32 + return ( 33 + <PageWrapper 34 + pageType="doc" 35 + fullPageScroll={false} 36 + id={`post-page-${pageId}`} 37 + drawerOpen={!!drawer} 38 + pageOptions={pageOptions} 39 + > 40 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 41 + <div className="text-secondary font-bold mb-3 flex items-center gap-2"> 42 + <QuoteTiny /> 43 + Bluesky Quotes 44 + </div> 45 + {isLoading ? ( 46 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 47 + <span>loading quotes</span> 48 + <DotLoader /> 49 + </div> 50 + ) : error ? ( 51 + <div className="text-tertiary italic text-sm text-center py-8"> 52 + Failed to load quotes 53 + </div> 54 + ) : quotesData && quotesData.posts.length > 0 ? ( 55 + <QuotesContent posts={quotesData.posts} postUri={postUri} /> 56 + ) : ( 57 + <div className="text-tertiary italic text-sm text-center py-8"> 58 + No quotes yet 59 + </div> 60 + )} 61 + </div> 62 + </PageWrapper> 63 + ); 64 + } 65 + 66 + function QuotesContent(props: { posts: PostView[]; postUri: string }) { 67 + const { posts, postUri } = props; 68 + 69 + return ( 70 + <div className="flex flex-col gap-0"> 71 + {posts.map((post) => ( 72 + <QuotePost 73 + key={post.uri} 74 + post={post} 75 + quotesUri={postUri} 76 + /> 77 + ))} 78 + </div> 79 + ); 80 + } 81 + 82 + function QuotePost(props: { 83 + post: PostView; 84 + quotesUri: string; 85 + }) { 86 + const { post, quotesUri } = props; 87 + const parent = { type: "quotes" as const, uri: quotesUri }; 88 + 89 + return ( 90 + <div 91 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 92 + onClick={() => openPage(parent, { type: "thread", uri: post.uri })} 93 + > 94 + <BskyPostContent 95 + post={post} 96 + parent={parent} 97 + linksEnabled={true} 98 + showEmbed={true} 99 + showBlueskyLink={true} 100 + onLinkClick={(e) => e.stopPropagation()} 101 + onEmbedClick={(e) => e.stopPropagation()} 102 + /> 103 + </div> 104 + ); 105 + }
+182
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import { 4 + BlueskyEmbed, 5 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 6 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 7 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 + import { CommentTiny } from "components/Icons/CommentTiny"; 9 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 10 + import { Separator } from "components/Layout"; 11 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 13 + import { OpenPage } from "./PostPages"; 14 + import { ThreadLink, QuotesLink } from "./PostLinks"; 15 + 16 + type PostView = AppBskyFeedDefs.PostView; 17 + 18 + export function BskyPostContent(props: { 19 + post: PostView; 20 + parent?: OpenPage; 21 + linksEnabled?: boolean; 22 + avatarSize?: "sm" | "md"; 23 + showEmbed?: boolean; 24 + showBlueskyLink?: boolean; 25 + onEmbedClick?: (e: React.MouseEvent) => void; 26 + onLinkClick?: (e: React.MouseEvent) => void; 27 + }) { 28 + const { 29 + post, 30 + parent, 31 + linksEnabled = true, 32 + avatarSize = "md", 33 + showEmbed = true, 34 + showBlueskyLink = true, 35 + onEmbedClick, 36 + onLinkClick, 37 + } = props; 38 + 39 + const record = post.record as AppBskyFeedPost.Record; 40 + const postId = post.uri.split("/")[4]; 41 + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 + 43 + const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 44 + 45 + return ( 46 + <> 47 + <div className="flex flex-col items-center shrink-0"> 48 + {post.author.avatar ? ( 49 + <img 50 + src={post.author.avatar} 51 + alt={`${post.author.displayName}'s avatar`} 52 + className={`${avatarClass} rounded-full border border-border-light`} 53 + /> 54 + ) : ( 55 + <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 + )} 57 + </div> 58 + 59 + <div className="flex flex-col grow min-w-0"> 60 + <div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}> 61 + <div className="font-bold text-secondary"> 62 + {post.author.displayName} 63 + </div> 64 + <a 65 + className="text-xs text-tertiary hover:underline" 66 + target="_blank" 67 + href={`https://bsky.app/profile/${post.author.handle}`} 68 + onClick={onLinkClick} 69 + > 70 + @{post.author.handle} 71 + </a> 72 + </div> 73 + 74 + <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 75 + <div className="text-sm text-secondary"> 76 + <BlueskyRichText record={record} /> 77 + </div> 78 + {showEmbed && post.embed && ( 79 + <div onClick={onEmbedClick}> 80 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 81 + </div> 82 + )} 83 + </div> 84 + 85 + <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 + <ClientDate date={record.createdAt} /> 87 + <PostCounts 88 + post={post} 89 + parent={parent} 90 + linksEnabled={linksEnabled} 91 + showBlueskyLink={showBlueskyLink} 92 + url={url} 93 + onLinkClick={onLinkClick} 94 + /> 95 + </div> 96 + </div> 97 + </> 98 + ); 99 + } 100 + 101 + function PostCounts(props: { 102 + post: PostView; 103 + parent?: OpenPage; 104 + linksEnabled: boolean; 105 + showBlueskyLink: boolean; 106 + url: string; 107 + onLinkClick?: (e: React.MouseEvent) => void; 108 + }) { 109 + const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 110 + 111 + return ( 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <Separator classname="h-3" /> 116 + {linksEnabled ? ( 117 + <ThreadLink 118 + threadUri={post.uri} 119 + parent={parent} 120 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 + onClick={onLinkClick} 122 + > 123 + {post.replyCount} 124 + <CommentTiny /> 125 + </ThreadLink> 126 + ) : ( 127 + <div className="flex items-center gap-1 text-tertiary text-xs"> 128 + {post.replyCount} 129 + <CommentTiny /> 130 + </div> 131 + )} 132 + </> 133 + )} 134 + {post.quoteCount != null && post.quoteCount > 0 && ( 135 + <> 136 + <Separator classname="h-3" /> 137 + <QuotesLink 138 + postUri={post.uri} 139 + parent={parent} 140 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 141 + onClick={onLinkClick} 142 + > 143 + {post.quoteCount} 144 + <QuoteTiny /> 145 + </QuotesLink> 146 + </> 147 + )} 148 + {showBlueskyLink && ( 149 + <> 150 + <Separator classname="h-3" /> 151 + <a 152 + className="text-tertiary" 153 + target="_blank" 154 + href={url} 155 + onClick={onLinkClick} 156 + > 157 + <BlueskyTiny /> 158 + </a> 159 + </> 160 + )} 161 + </div> 162 + ); 163 + } 164 + 165 + export const ClientDate = (props: { date?: string }) => { 166 + const pageLoaded = useHasPageLoaded(); 167 + const formattedDate = useLocalizedDate( 168 + props.date || new Date().toISOString(), 169 + { 170 + month: "short", 171 + day: "numeric", 172 + year: "numeric", 173 + hour: "numeric", 174 + minute: "numeric", 175 + hour12: true, 176 + }, 177 + ); 178 + 179 + if (!pageLoaded) return null; 180 + 181 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 182 + };
+13 -8
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, ··· 56 57 <PageWrapper 57 58 pageType="canvas" 58 59 fullPageScroll={fullPageScroll} 59 - cardBorderHidden={!hasPageBackground} 60 - id={pageId ? `post-page-${pageId}` : "post-page"} 60 + id={`post-page-${pageId ?? document_uri}`} 61 61 drawerOpen={ 62 62 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 63 63 } ··· 202 202 isSubpage: boolean | undefined; 203 203 data: PostPageData; 204 204 profile: ProfileViewDetailed; 205 - preferences: { showComments?: boolean }; 205 + preferences: { 206 + showComments?: boolean; 207 + showMentions?: boolean; 208 + showPrevNext?: boolean; 209 + }; 206 210 quotesCount: number | undefined; 207 211 commentsCount: number | undefined; 208 212 }) => { 213 + let isMobile = useIsMobile(); 209 214 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"> 215 + <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 216 <Interactions 212 217 quotesCount={props.quotesCount || 0} 213 218 commentsCount={props.commentsCount || 0} 214 - compact 215 219 showComments={props.preferences.showComments} 220 + showMentions={props.preferences.showMentions} 216 221 pageId={props.pageId} 217 222 /> 218 223 {!props.isSubpage && ( 219 224 <> 220 225 <Separator classname="h-5" /> 221 226 <Popover 222 - side="left" 223 - align="start" 224 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 227 + side="bottom" 228 + align="end" 229 + className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`} 225 230 trigger={<InfoSmall />} 226 231 > 227 232 <PostHeader
+247 -16
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 1 + import { AtUri, UnicodeString } from "@atproto/api"; 2 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 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 { useToaster } from "components/Toast"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 + import { Mention, MentionAutocomplete } from "components/Mention"; 44 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 45 + 46 + const addMentionToEditor = ( 47 + mention: Mention, 48 + range: { from: number; to: number }, 49 + view: EditorView, 50 + ) => { 51 + if (!view) return; 52 + const { from, to } = range; 53 + const tr = view.state.tr; 54 + 55 + if (mention.type === "did") { 56 + // Delete the @ and any query text 57 + tr.delete(from, to); 58 + // Insert didMention inline node 59 + const mentionText = "@" + mention.handle; 60 + const didMentionNode = multiBlockSchema.nodes.didMention.create({ 61 + did: mention.did, 62 + text: mentionText, 63 + }); 64 + tr.insert(from, didMentionNode); 65 + // Add a space after the mention 66 + tr.insertText(" ", from + 1); 67 + } 68 + if (mention.type === "publication" || mention.type === "post") { 69 + // Delete the @ and any query text 70 + tr.delete(from, to); 71 + let name = mention.type === "post" ? mention.title : mention.name; 72 + // Insert atMention inline node 73 + const atMentionNode = multiBlockSchema.nodes.atMention.create({ 74 + atURI: mention.uri, 75 + text: name, 76 + }); 77 + tr.insert(from, atMentionNode); 78 + // Add a space after the mention 79 + tr.insertText(" ", from + 1); 80 + } 81 + 82 + view.dispatch(tr); 83 + view.focus(); 84 + }; 39 85 40 86 export function CommentBox(props: { 41 87 doc_uri: string; ··· 50 96 commentBox: { quote }, 51 97 } = useInteractionState(props.doc_uri); 52 98 let [loading, setLoading] = useState(false); 99 + let view = useRef<null | EditorView>(null); 100 + let toaster = useToaster(); 53 101 54 - const handleSubmit = async () => { 102 + // Mention autocomplete state 103 + const [mentionOpen, setMentionOpen] = useState(false); 104 + const [mentionCoords, setMentionCoords] = useState<{ 105 + top: number; 106 + left: number; 107 + } | null>(null); 108 + // Use a ref for insert position to avoid stale closure issues 109 + const mentionInsertPosRef = useRef<number | null>(null); 110 + 111 + // Use a ref for the callback so input rules can access it 112 + const openMentionAutocompleteRef = useRef<() => void>(() => {}); 113 + openMentionAutocompleteRef.current = () => { 114 + if (!view.current) return; 115 + 116 + const pos = view.current.state.selection.from; 117 + mentionInsertPosRef.current = pos; 118 + 119 + // Get coordinates for the popup relative to the positioned parent 120 + const coords = view.current.coordsAtPos(pos - 1); 121 + 122 + // Find the relative positioned parent container 123 + const editorEl = view.current.dom; 124 + const container = editorEl.closest(".relative") as HTMLElement | null; 125 + 126 + if (container) { 127 + const containerRect = container.getBoundingClientRect(); 128 + setMentionCoords({ 129 + top: coords.bottom - containerRect.top, 130 + left: coords.left - containerRect.left, 131 + }); 132 + } else { 133 + setMentionCoords({ 134 + top: coords.bottom, 135 + left: coords.left, 136 + }); 137 + } 138 + setMentionOpen(true); 139 + }; 140 + 141 + const handleMentionSelect = useCallback((mention: Mention) => { 142 + if (!view.current || mentionInsertPosRef.current === null) return; 143 + 144 + const from = mentionInsertPosRef.current - 1; 145 + const to = mentionInsertPosRef.current; 146 + 147 + addMentionToEditor(mention, { from, to }, view.current); 148 + view.current.focus(); 149 + }, []); 150 + 151 + const handleMentionOpenChange = useCallback((open: boolean) => { 152 + setMentionOpen(open); 153 + if (!open) { 154 + setMentionCoords(null); 155 + mentionInsertPosRef.current = null; 156 + } 157 + }, []); 158 + 159 + // Use a ref for handleSubmit so keyboard shortcuts can access it 160 + const handleSubmitRef = useRef<() => Promise<void>>(async () => {}); 161 + handleSubmitRef.current = async () => { 55 162 if (loading || !view.current) return; 56 163 57 164 setLoading(true); 58 165 let currentState = view.current.state; 59 166 let [plaintext, facets] = docToFacetedText(currentState.doc); 60 - let comment = await publishComment({ 167 + let result = await publishComment({ 61 168 pageId: props.pageId, 62 169 document: props.doc_uri, 63 170 comment: { ··· 74 181 }, 75 182 }); 76 183 184 + if (!result.success) { 185 + setLoading(false); 186 + toaster({ 187 + content: isOAuthSessionError(result.error) ? ( 188 + <OAuthErrorMessage error={result.error} /> 189 + ) : ( 190 + "Failed to post comment" 191 + ), 192 + type: "error", 193 + }); 194 + return; 195 + } 196 + 77 197 let tr = currentState.tr; 78 198 tr = tr.replaceWith( 79 199 0, ··· 90 210 localComments: [ 91 211 ...s.localComments, 92 212 { 93 - record: comment.record, 94 - uri: comment.uri, 95 - bsky_profiles: { record: comment.profile as Json }, 213 + record: result.record, 214 + uri: result.uri, 215 + bsky_profiles: { 216 + record: result.profile as Json, 217 + did: new AtUri(result.uri).host, 218 + }, 96 219 }, 97 220 ], 98 221 })); ··· 114 237 "Mod-y": redo, 115 238 "Shift-Mod-z": redo, 116 239 "Ctrl-Enter": () => { 117 - handleSubmit(); 240 + handleSubmitRef.current(); 118 241 return true; 119 242 }, 120 243 "Meta-Enter": () => { 121 - handleSubmit(); 244 + handleSubmitRef.current(); 122 245 return true; 123 246 }, 124 247 }), ··· 127 250 type: multiBlockSchema.marks.link, 128 251 shouldAutoLink: () => true, 129 252 defaultProtocol: "https", 253 + }), 254 + // Input rules for @ mentions 255 + inputRules({ 256 + rules: [ 257 + // @ at start of line or after space 258 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 259 + setTimeout(() => openMentionAutocompleteRef.current(), 0); 260 + return null; 261 + }), 262 + ], 130 263 }), 131 264 history(), 132 265 ], 133 266 }), 134 267 ); 135 - let view = useRef<null | EditorView>(null); 136 268 useLayoutEffect(() => { 137 269 if (!mountRef.current) return; 138 270 view.current = new EditorView( ··· 187 319 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 320 if (!direct) return; 189 321 if (node.nodeSize - 2 <= _pos) return; 322 + 323 + const nodeAt1 = node.nodeAt(_pos - 1); 324 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 325 + 326 + // Check for link marks 190 327 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); 328 + nodeAt1?.marks.find( 329 + (f) => f.type === multiBlockSchema.marks.link, 330 + ) || 331 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 332 if (mark) { 198 333 window.open(mark.attrs.href, "_blank"); 334 + return; 335 + } 336 + 337 + // Check for didMention inline nodes 338 + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { 339 + window.open( 340 + didToBlueskyUrl(nodeAt1.attrs.did), 341 + "_blank", 342 + "noopener,noreferrer", 343 + ); 344 + return; 345 + } 346 + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { 347 + window.open( 348 + didToBlueskyUrl(nodeAt2.attrs.did), 349 + "_blank", 350 + "noopener,noreferrer", 351 + ); 352 + return; 353 + } 354 + 355 + // Check for atMention inline nodes (publications/documents) 356 + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { 357 + window.open( 358 + atUriToUrl(nodeAt1.attrs.atURI), 359 + "_blank", 360 + "noopener,noreferrer", 361 + ); 362 + return; 363 + } 364 + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { 365 + window.open( 366 + atUriToUrl(nodeAt2.attrs.atURI), 367 + "_blank", 368 + "noopener,noreferrer", 369 + ); 370 + return; 199 371 } 200 372 }, 201 373 dispatchTransaction(tr) { ··· 236 408 <div className="w-full relative group"> 237 409 <pre 238 410 ref={mountRef} 411 + onFocus={() => { 412 + // Close mention dropdown when editor gains focus (reset stale state) 413 + handleMentionOpenChange(false); 414 + }} 415 + onBlur={(e) => { 416 + // Close mention dropdown when editor loses focus 417 + // But not if focus moved to the mention autocomplete 418 + const relatedTarget = e.relatedTarget as HTMLElement | null; 419 + if (!relatedTarget?.closest(".dropdownMenu")) { 420 + handleMentionOpenChange(false); 421 + } 422 + }} 239 423 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 424 /> 241 425 <IOSBS view={view} /> 426 + <MentionAutocomplete 427 + open={mentionOpen} 428 + onOpenChange={handleMentionOpenChange} 429 + view={view} 430 + onSelect={handleMentionSelect} 431 + coords={mentionCoords} 432 + /> 242 433 </div> 243 434 <div className="flex justify-between pt-1"> 244 435 <div className="flex gap-1"> ··· 261 452 view={view} 262 453 /> 263 454 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 455 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 265 456 {loading ? <DotLoader /> : <ShareSmall />} 266 457 </ButtonPrimary> 267 458 </div> ··· 328 519 facets.push(facet); 329 520 } 330 521 } 522 + 523 + fullText += text; 524 + byteOffset += unicodeString.length; 525 + } else if (node.type.name === "didMention") { 526 + // Handle DID mention nodes 527 + const text = node.attrs.text || ""; 528 + const unicodeString = new UnicodeString(text); 529 + 530 + facets.push({ 531 + index: { 532 + byteStart: byteOffset, 533 + byteEnd: byteOffset + unicodeString.length, 534 + }, 535 + features: [ 536 + { 537 + $type: "pub.leaflet.richtext.facet#didMention", 538 + did: node.attrs.did, 539 + }, 540 + ], 541 + }); 542 + 543 + fullText += text; 544 + byteOffset += unicodeString.length; 545 + } else if (node.type.name === "atMention") { 546 + // Handle AT-URI mention nodes (publications and documents) 547 + const text = node.attrs.text || ""; 548 + const unicodeString = new UnicodeString(text); 549 + 550 + facets.push({ 551 + index: { 552 + byteStart: byteOffset, 553 + byteEnd: byteOffset + unicodeString.length, 554 + }, 555 + features: [ 556 + { 557 + $type: "pub.leaflet.richtext.facet#atMention", 558 + atURI: node.attrs.atURI, 559 + }, 560 + ], 561 + }); 331 562 332 563 fullText += text; 333 564 byteOffset += unicodeString.length;
+123 -6
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 3 3 import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 12 import { supabaseServerClient } from "supabase/serverClient"; 10 13 import { Json } from "supabase/database.types"; 11 14 import { 12 15 Notification, 16 + NotificationData, 13 17 pingIdentityToUpdateNotification, 14 18 } from "src/notifications"; 15 19 import { v7 } from "uuid"; 16 20 21 + type PublishCommentResult = 22 + | { success: true; record: Json; profile: any; uri: string } 23 + | { success: false; error: OAuthSessionError }; 24 + 17 25 export async function publishComment(args: { 18 26 document: string; 19 27 pageId?: string; ··· 23 31 replyTo?: string; 24 32 attachment: PubLeafletComment.Record["attachment"]; 25 33 }; 26 - }) { 27 - const oauthClient = await createOauthClient(); 34 + }): Promise<PublishCommentResult> { 28 35 let identity = await getIdentityData(); 29 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 36 + if (!identity || !identity.atp_did) { 37 + return { 38 + success: false, 39 + error: { 40 + type: "oauth_session_expired", 41 + message: "Not authenticated", 42 + did: "", 43 + }, 44 + }; 45 + } 30 46 31 - let credentialSession = await oauthClient.restore(identity.atp_did); 47 + const sessionResult = await restoreOAuthSession(identity.atp_did); 48 + if (!sessionResult.ok) { 49 + return { success: false, error: sessionResult.error }; 50 + } 51 + let credentialSession = sessionResult.value; 32 52 let agent = new AtpBaseClient( 33 53 credentialSession.fetchHandler.bind(credentialSession), 34 54 ); ··· 84 104 parent_uri: args.comment.replyTo, 85 105 }, 86 106 }); 107 + } 108 + 109 + // Create mention notifications from comment facets 110 + const mentionNotifications = createCommentMentionNotifications( 111 + args.comment.facets, 112 + uri.toString(), 113 + credentialSession.did!, 114 + ); 115 + notifications.push(...mentionNotifications); 116 + 117 + // Insert all notifications and ping recipients 118 + if (notifications.length > 0) { 87 119 // SOMEDAY: move this out the action with inngest or workflows 88 120 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 121 + 122 + // Ping all unique recipients 123 + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; 124 + await Promise.all( 125 + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), 126 + ); 90 127 } 91 128 92 129 return { 130 + success: true, 93 131 record: data?.[0].record as Json, 94 132 profile: lexToJson(profile.value), 95 133 uri: uri.toString(), 96 134 }; 97 135 } 136 + 137 + /** 138 + * Creates mention notifications from comment facets 139 + * Handles didMention (people) and atMention (publications/documents) 140 + */ 141 + function createCommentMentionNotifications( 142 + facets: PubLeafletRichtextFacet.Main[], 143 + commentUri: string, 144 + commenterDid: string, 145 + ): Notification[] { 146 + const notifications: Notification[] = []; 147 + const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications 148 + 149 + for (const facet of facets) { 150 + for (const feature of facet.features) { 151 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 152 + // DID mention - notify the mentioned person directly 153 + const recipientDid = feature.did; 154 + 155 + // Don't notify yourself 156 + if (recipientDid === commenterDid) continue; 157 + // Avoid duplicate notifications to the same person 158 + if (notifiedRecipients.has(recipientDid)) continue; 159 + notifiedRecipients.add(recipientDid); 160 + 161 + notifications.push({ 162 + id: v7(), 163 + recipient: recipientDid, 164 + data: { 165 + type: "comment_mention", 166 + comment_uri: commentUri, 167 + mention_type: "did", 168 + }, 169 + }); 170 + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { 171 + // AT-URI mention - notify the owner of the publication/document 172 + try { 173 + const mentionedUri = new AtUri(feature.atURI); 174 + const recipientDid = mentionedUri.host; 175 + 176 + // Don't notify yourself 177 + if (recipientDid === commenterDid) continue; 178 + // Avoid duplicate notifications to the same person for the same mentioned item 179 + const dedupeKey = `${recipientDid}:${feature.atURI}`; 180 + if (notifiedRecipients.has(dedupeKey)) continue; 181 + notifiedRecipients.add(dedupeKey); 182 + 183 + if (mentionedUri.collection === "pub.leaflet.publication") { 184 + notifications.push({ 185 + id: v7(), 186 + recipient: recipientDid, 187 + data: { 188 + type: "comment_mention", 189 + comment_uri: commentUri, 190 + mention_type: "publication", 191 + mentioned_uri: feature.atURI, 192 + }, 193 + }); 194 + } else if (mentionedUri.collection === "pub.leaflet.document") { 195 + notifications.push({ 196 + id: v7(), 197 + recipient: recipientDid, 198 + data: { 199 + type: "comment_mention", 200 + comment_uri: commentUri, 201 + mention_type: "document", 202 + mentioned_uri: feature.atURI, 203 + }, 204 + }); 205 + } 206 + } catch (error) { 207 + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); 208 + } 209 + } 210 + } 211 + } 212 + 213 + return notifications; 214 + }
+19 -100
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 18 18 import { QuoteContent } from "../Quotes"; 19 19 import { timeAgo } from "src/utils/timeAgo"; 20 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 21 22 22 23 export type Comment = { 23 24 record: Json; 24 25 uri: string; 25 - bsky_profiles: { record: Json } | null; 26 + bsky_profiles: { record: Json; did: string } | null; 26 27 }; 27 28 export function Comments(props: { 28 29 document_uri: string; ··· 50 51 }, []); 51 52 52 53 return ( 53 - <div id={"commentsDrawer"} className="flex flex-col gap-2 relative"> 54 + <div 55 + id={"commentsDrawer"} 56 + className="flex flex-col gap-2 relative text-sm text-secondary" 57 + > 54 58 <div className="w-full flex justify-between text-secondary font-bold"> 55 59 Comments 56 60 <button ··· 109 113 document: string; 110 114 comment: Comment; 111 115 comments: Comment[]; 112 - profile?: AppBskyActorProfile.Record; 116 + profile: AppBskyActorProfile.Record; 113 117 record: PubLeafletComment.Record; 114 118 pageId?: string; 115 119 }) => { 120 + const did = props.comment.bsky_profiles?.did; 121 + 116 122 return ( 117 - <div className="comment"> 123 + <div id={props.comment.uri} className="comment"> 118 124 <div className="flex gap-2"> 119 - {props.profile && ( 120 - <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 125 + {did && ( 126 + <ProfilePopover 127 + didOrHandle={did} 128 + trigger={ 129 + <div className="text-sm text-tertiary font-bold hover:underline"> 130 + {props.profile.displayName} 131 + </div> 132 + } 133 + /> 121 134 )} 122 - <DatePopover date={props.record.createdAt} /> 123 135 </div> 124 136 {props.record.attachment && 125 137 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 291 303 </Popover> 292 304 ); 293 305 }; 294 - 295 - const ProfilePopover = (props: { 296 - profile: AppBskyActorProfile.Record; 297 - comment: string; 298 - }) => { 299 - let commenterId = new AtUri(props.comment).host; 300 - 301 - return ( 302 - <> 303 - <a 304 - className="font-bold text-tertiary text-sm hover:underline" 305 - href={`https://bsky.app/profile/${commenterId}`} 306 - > 307 - {props.profile.displayName} 308 - </a> 309 - {/*<Media mobile={false}> 310 - <Popover 311 - align="start" 312 - trigger={ 313 - <div 314 - onMouseOver={() => { 315 - setHovering(true); 316 - hoverTimeout.current = window.setTimeout(() => { 317 - setLoadProfile(true); 318 - }, 500); 319 - }} 320 - onMouseOut={() => { 321 - setHovering(false); 322 - clearTimeout(hoverTimeout.current); 323 - }} 324 - className="font-bold text-tertiary text-sm hover:underline" 325 - > 326 - {props.profile.displayName} 327 - </div> 328 - } 329 - className="max-w-sm" 330 - > 331 - {profile && ( 332 - <> 333 - <div className="profilePopover text-sm flex gap-2"> 334 - <div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" /> 335 - <div className="flex flex-col"> 336 - <div className="flex justify-between"> 337 - <div className="profileHeader flex gap-2 items-center"> 338 - <div className="font-bold">celine</div> 339 - <a className="text-tertiary" href="/"> 340 - @{profile.handle} 341 - </a> 342 - </div> 343 - </div> 344 - 345 - <div className="profileBio text-secondary "> 346 - {profile.description} 347 - </div> 348 - <div className="flex flex-row gap-2 items-center pt-2 font-bold"> 349 - {!profile.viewer?.following ? ( 350 - <div className="text-tertiary bg-border-light rounded-md px-1 py-0"> 351 - Following 352 - </div> 353 - ) : ( 354 - <ButtonPrimary compact className="text-sm"> 355 - Follow <BlueskyTiny /> 356 - </ButtonPrimary> 357 - )} 358 - {profile.viewer?.followedBy && ( 359 - <div className="text-tertiary">Follows You</div> 360 - )} 361 - </div> 362 - </div> 363 - </div> 364 - 365 - <hr className="my-2 border-border-light" /> 366 - <div className="flex gap-2 leading-tight items-center text-tertiary text-sm"> 367 - <div className="flex flex-col w-6 justify-center"> 368 - {profile.viewer?.knownFollowers?.followers.map((follower) => { 369 - return ( 370 - <div 371 - className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page" 372 - key={follower.did} 373 - /> 374 - ); 375 - })} 376 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 377 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 378 - </div> 379 - </div> 380 - </> 381 - )} 382 - </Popover> 383 - </Media>*/} 384 - </> 385 - ); 386 - };
+6 -2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 9 9 import { decodeQuotePosition } from "../quotePosition"; 10 10 11 11 export const InteractionDrawer = (props: { 12 + showPageBackground: boolean | undefined; 12 13 document_uri: string; 13 14 quotesAndMentions: { uri: string; link?: string }[]; 14 15 comments: Comment[]; ··· 38 39 <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 40 <div 40 41 id="interaction-drawer" 41 - className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 42 + className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`} 42 43 > 43 44 {drawer.drawer === "quotes" ? ( 44 45 <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> ··· 58 59 export const useDrawerOpen = (uri: string) => { 59 60 let params = useSearchParams(); 60 61 let interactionDrawerSearchParam = params.get("interactionDrawer"); 62 + let pageParam = params.get("page"); 61 63 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 64 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 65 return null; 64 66 drawer = 65 67 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 - return { drawer, pageId }; 68 + // Use pageId from state, or fall back to page search param 69 + const resolvedPageId = pageId ?? pageParam ?? undefined; 70 + return { drawer, pageId: resolvedPageId }; 67 71 };
+229 -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; 111 + showMentions?: boolean; 105 112 pageId?: string; 106 113 }) => { 107 114 const data = useContext(PostPageContext); 108 115 const document_uri = data?.uri; 116 + let { identity } = useIdentityData(); 109 117 if (!document_uri) 110 118 throw new Error("document_uri not available in PostPageContext"); 111 119 ··· 117 125 } 118 126 }; 119 127 128 + const tags = (data?.data as any)?.tags as string[] | undefined; 129 + const tagCount = tags?.length || 0; 130 + 120 131 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> 132 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 133 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 134 + 135 + {props.quotesCount === 0 || props.showMentions === false ? null : ( 136 + <button 137 + className="flex w-fit gap-2 items-center" 138 + onClick={() => { 139 + if (!drawerOpen || drawer !== "quotes") 140 + openInteractionDrawer("quotes", document_uri, props.pageId); 141 + else setInteractionState(document_uri, { drawerOpen: false }); 142 + }} 143 + onMouseEnter={handleQuotePrefetch} 144 + onTouchStart={handleQuotePrefetch} 145 + aria-label="Post quotes" 146 + > 147 + <QuoteTiny aria-hidden /> {props.quotesCount} 148 + </button> 149 + )} 142 150 {props.showComments === false ? null : ( 143 151 <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"}`} 152 + className="flex gap-2 items-center w-fit" 145 153 onClick={() => { 146 154 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 155 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 157 }} 150 158 aria-label="Post comments" 151 159 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 160 + <CommentTiny aria-hidden /> {props.commentsCount} 158 161 </button> 159 162 )} 160 163 </div> 161 164 ); 162 165 }; 163 166 167 + export const ExpandedInteractions = (props: { 168 + quotesCount: number; 169 + commentsCount: number; 170 + className?: string; 171 + showComments?: boolean; 172 + showMentions?: boolean; 173 + pageId?: string; 174 + }) => { 175 + const data = useContext(PostPageContext); 176 + let { identity } = useIdentityData(); 177 + 178 + const document_uri = data?.uri; 179 + if (!document_uri) 180 + throw new Error("document_uri not available in PostPageContext"); 181 + 182 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 183 + 184 + const handleQuotePrefetch = () => { 185 + if (data?.quotesAndMentions) { 186 + prefetchQuotesData(data.quotesAndMentions); 187 + } 188 + }; 189 + let publication = data?.documents_in_publications[0]?.publications; 190 + 191 + const tags = (data?.data as any)?.tags as string[] | undefined; 192 + const tagCount = tags?.length || 0; 193 + 194 + let noInteractions = !props.showComments && !props.showMentions; 195 + 196 + let subscribed = 197 + identity?.atp_did && 198 + publication?.publication_subscriptions && 199 + publication?.publication_subscriptions.find( 200 + (s) => s.identity === identity.atp_did, 201 + ); 202 + 203 + let isAuthor = 204 + identity && 205 + identity.atp_did === 206 + data.documents_in_publications[0]?.publications?.identity_did && 207 + data.leaflets_in_publications[0]; 208 + 209 + return ( 210 + <div 211 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 212 + > 213 + {!subscribed && !isAuthor && publication && publication.record && ( 214 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 215 + <div className="flex flex-col py-4"> 216 + <div className="leading-snug flex flex-col pb-2 text-sm"> 217 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 218 + to get updates in Reader, RSS, or via Bluesky Feed 219 + </div> 220 + <SubscribeWithBluesky 221 + pubName={publication.name} 222 + pub_uri={publication.uri} 223 + base_url={getPublicationURL(publication)} 224 + subscribers={publication?.publication_subscriptions} 225 + /> 226 + </div> 227 + </div> 228 + )} 229 + {tagCount > 0 && ( 230 + <> 231 + <hr className="border-border-light mb-3" /> 232 + 233 + <TagList tags={tags} className="mb-3" /> 234 + </> 235 + )} 236 + 237 + <hr className="border-border-light mb-3 " /> 238 + 239 + <div className="flex gap-2 justify-between"> 240 + {noInteractions ? ( 241 + <div /> 242 + ) : ( 243 + <> 244 + <div className="flex gap-2"> 245 + {props.quotesCount === 0 || 246 + props.showMentions === false ? null : ( 247 + <button 248 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 249 + onClick={() => { 250 + if (!drawerOpen || drawer !== "quotes") 251 + openInteractionDrawer( 252 + "quotes", 253 + document_uri, 254 + props.pageId, 255 + ); 256 + else 257 + setInteractionState(document_uri, { drawerOpen: false }); 258 + }} 259 + onMouseEnter={handleQuotePrefetch} 260 + onTouchStart={handleQuotePrefetch} 261 + aria-label="Post quotes" 262 + > 263 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 264 + <span 265 + aria-hidden 266 + >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 267 + </button> 268 + )} 269 + {props.showComments === false ? null : ( 270 + <button 271 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 272 + onClick={() => { 273 + if ( 274 + !drawerOpen || 275 + drawer !== "comments" || 276 + pageId !== props.pageId 277 + ) 278 + openInteractionDrawer( 279 + "comments", 280 + document_uri, 281 + props.pageId, 282 + ); 283 + else 284 + setInteractionState(document_uri, { drawerOpen: false }); 285 + }} 286 + aria-label="Post comments" 287 + > 288 + <CommentTiny aria-hidden />{" "} 289 + {props.commentsCount > 0 ? ( 290 + <span aria-hidden> 291 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 292 + </span> 293 + ) : ( 294 + "Comment" 295 + )} 296 + </button> 297 + )} 298 + </div> 299 + </> 300 + )} 301 + 302 + <EditButton document={data} /> 303 + {subscribed && publication && ( 304 + <ManageSubscription 305 + base_url={getPublicationURL(publication)} 306 + pub_uri={publication.uri} 307 + subscribers={publication.publication_subscriptions} 308 + /> 309 + )} 310 + </div> 311 + </div> 312 + ); 313 + }; 314 + 315 + const TagPopover = (props: { 316 + tagCount: number; 317 + tags: string[] | undefined; 318 + }) => { 319 + return ( 320 + <Popover 321 + className="p-2! max-w-xs" 322 + trigger={ 323 + <div className="tags flex gap-1 items-center "> 324 + <TagTiny /> {props.tagCount} 325 + </div> 326 + } 327 + > 328 + <TagList tags={props.tags} className="text-secondary!" /> 329 + </Popover> 330 + ); 331 + }; 332 + 333 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 334 + if (!props.tags) return; 335 + return ( 336 + <div className="flex gap-1 flex-wrap"> 337 + {props.tags.map((tag, index) => ( 338 + <Tag name={tag} key={index} className={props.className} /> 339 + ))} 340 + </div> 341 + ); 342 + }; 164 343 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 344 if (!document) return; 166 345 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 377 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 378 ).length; 200 379 } 380 + 381 + const EditButton = (props: { document: PostPageData }) => { 382 + let { identity } = useIdentityData(); 383 + if (!props.document) return; 384 + if ( 385 + identity && 386 + identity.atp_did === 387 + props.document.documents_in_publications[0]?.publications?.identity_did && 388 + props.document.leaflets_in_publications[0] 389 + ) 390 + return ( 391 + <a 392 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 393 + 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" 394 + > 395 + <EditTiny /> Edit Post 396 + </a> 397 + ); 398 + return; 399 + };
+58 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 4 4 import { useIsMobile } from "src/hooks/isMobile"; 5 5 import { setInteractionState } from "./Interactions"; 6 6 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 - import { AtUri } from "@atproto/api"; 7 + import { AtUri, AppBskyFeedPost } from "@atproto/api"; 8 8 import { PostPageContext } from "../PostPageContext"; 9 9 import { 10 10 PubLeafletBlocksText, ··· 22 22 import { openPage } from "../PostPages"; 23 23 import useSWR, { mutate } from "swr"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 + import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { ThreadLink, QuotesLink } from "../PostLinks"; 25 28 26 29 // Helper to get SWR key for quotes 27 30 export function getQuotesSWRKey(uris: string[]) { ··· 129 132 130 133 <div className="h-5 w-1 ml-5 border-l border-border-light" /> 131 134 <BskyPost 135 + uri={pv.uri} 132 136 rkey={new AtUri(pv.uri).rkey} 133 137 content={pv.record.text as string} 134 138 user={pv.author.displayName || pv.author.handle} 135 139 profile={pv.author} 136 140 handle={pv.author.handle} 141 + replyCount={pv.replyCount} 142 + quoteCount={pv.quoteCount} 137 143 /> 138 144 </div> 139 145 ); ··· 150 156 return ( 151 157 <BskyPost 152 158 key={`mention-${index}`} 159 + uri={pv.uri} 153 160 rkey={new AtUri(pv.uri).rkey} 154 161 content={pv.record.text as string} 155 162 user={pv.author.displayName || pv.author.handle} 156 163 profile={pv.author} 157 164 handle={pv.author.handle} 165 + replyCount={pv.replyCount} 166 + quoteCount={pv.quoteCount} 158 167 /> 159 168 ); 160 169 })} ··· 174 183 }) => { 175 184 let isMobile = useIsMobile(); 176 185 const data = useContext(PostPageContext); 186 + const document_uri = data?.uri; 177 187 178 188 let record = data?.data as PubLeafletDocument.Record; 179 189 let page: PubLeafletPagesLinearDocument.Main | undefined = ( ··· 201 211 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 202 212 onClick={(e) => { 203 213 if (props.position.pageId) 204 - flushSync(() => openPage(undefined, props.position.pageId!)); 214 + flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); 205 215 let scrollMargin = isMobile 206 216 ? 16 207 217 : e.currentTarget.getBoundingClientRect().top; 208 - let scrollContainer = window.document.getElementById("post-page"); 218 + let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 219 + let scrollContainer = window.document.getElementById(scrollContainerId); 209 220 let el = window.document.getElementById( 210 221 props.position.start.block.join("."), 211 222 ); ··· 239 250 }; 240 251 241 252 export const BskyPost = (props: { 253 + uri: string; 242 254 rkey: string; 243 255 content: string; 244 256 user: string; 245 257 handle: string; 246 258 profile: ProfileViewBasic; 259 + replyCount?: number; 260 + quoteCount?: number; 247 261 }) => { 262 + const handleOpenThread = () => { 263 + openPage(undefined, { type: "thread", uri: props.uri }); 264 + }; 265 + 248 266 return ( 249 - <a 250 - target="_blank" 251 - href={`https://bsky.app/profile/${props.handle}/post/${props.rkey}`} 252 - className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal" 267 + <div 268 + onClick={handleOpenThread} 269 + className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded" 253 270 > 254 271 {props.profile.avatar && ( 255 272 <img 256 - className="rounded-full w-6 h-6" 273 + className="rounded-full w-6 h-6 shrink-0" 257 274 src={props.profile.avatar} 258 275 alt={props.profile.displayName} 259 276 /> 260 277 )} 261 - <div className="flex flex-col"> 262 - <div className="flex items-center gap-2"> 278 + <div className="flex flex-col min-w-0"> 279 + <div className="flex items-center gap-2 flex-wrap"> 263 280 <div className="font-bold">{props.user}</div> 264 - <div className="text-tertiary">@{props.handle}</div> 281 + <a 282 + className="text-tertiary hover:underline" 283 + href={`https://bsky.app/profile/${props.handle}`} 284 + target="_blank" 285 + onClick={(e) => e.stopPropagation()} 286 + > 287 + @{props.handle} 288 + </a> 265 289 </div> 266 290 <div className="text-primary">{props.content}</div> 291 + <div className="flex gap-2 items-center mt-1"> 292 + {props.replyCount != null && props.replyCount > 0 && ( 293 + <ThreadLink 294 + threadUri={props.uri} 295 + onClick={(e) => e.stopPropagation()} 296 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 297 + > 298 + <CommentTiny /> 299 + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 300 + </ThreadLink> 301 + )} 302 + {props.quoteCount != null && props.quoteCount > 0 && ( 303 + <QuotesLink 304 + postUri={props.uri} 305 + onClick={(e) => e.stopPropagation()} 306 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 307 + > 308 + <QuoteTiny /> 309 + {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 310 + </QuotesLink> 311 + )} 312 + </div> 267 313 </div> 268 - </a> 314 + </div> 269 315 ); 270 316 }; 271 317
+11 -43
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 - Interactions, 17 17 } from "./Interactions/Interactions"; 18 18 import { PostContent } from "./PostContent"; 19 19 import { PostHeader } from "./PostHeader/PostHeader"; ··· 24 24 import { decodeQuotePosition } from "./quotePosition"; 25 25 import { PollData } from "./fetchPollData"; 26 26 import { SharedPageProps } from "./PostPages"; 27 + import { PostPrevNextButtons } from "./PostPrevNextButtons"; 27 28 28 29 export function LinearDocumentPage({ 29 30 blocks, ··· 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; ··· 56 56 57 57 const isSubpage = !!pageId; 58 58 59 + console.log("prev/next?: " + preferences.showPrevNext); 60 + 59 61 return ( 60 62 <> 61 63 <PageWrapper 62 64 pageType="doc" 63 65 fullPageScroll={fullPageScroll} 64 - cardBorderHidden={!hasPageBackground} 65 - id={pageId ? `post-page-${pageId}` : "post-page"} 66 + id={`post-page-${pageId ?? document_uri}`} 66 67 drawerOpen={ 67 68 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 68 69 } ··· 84 85 did={did} 85 86 prerenderedCodeBlocks={prerenderedCodeBlocks} 86 87 /> 87 - <Interactions 88 + <PostPrevNextButtons 89 + showPrevNext={preferences.showPrevNext && !isSubpage} 90 + /> 91 + <ExpandedInteractions 88 92 pageId={pageId} 89 93 showComments={preferences.showComments} 94 + showMentions={preferences.showMentions} 90 95 commentsCount={getCommentCount(document, pageId) || 0} 91 96 quotesCount={getQuoteCount(document, pageId) || 0} 92 97 /> 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 - )} 98 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 131 99 </PageWrapper> 132 100 </> 133 101 );
+12 -9
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 ( ··· 173 173 let uri = b.block.postRef.uri; 174 174 let post = bskyPostData.find((p) => p.uri === uri); 175 175 if (!post) return <div>no prefetched post rip</div>; 176 - return <PubBlueskyPostBlock post={post} className={className} />; 176 + return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />; 177 177 } 178 178 case PubLeafletBlocksIframe.isMain(b.block): { 179 179 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 - // };
+64 -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"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 19 22 20 23 export function PostHeader(props: { 21 24 data: PostPageData; 22 25 profile: ProfileViewDetailed; 23 - preferences: { showComments?: boolean }; 26 + preferences: { showComments?: boolean; showMentions?: boolean }; 24 27 }) { 25 28 let { identity } = useIdentityData(); 26 29 let document = props.data; ··· 40 43 41 44 if (!document?.data) return; 42 45 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"> 46 + <PostHeaderLayout 47 + pubLink={ 48 + <> 49 49 {pub && ( 50 50 <SpeedyLink 51 51 className="font-bold hover:no-underline text-accent-contrast" ··· 65 65 <EditTiny className="shrink-0" /> 66 66 </a> 67 67 )} 68 - </div> 69 - <h2 className="">{record.title}</h2> 70 - {record.description ? ( 71 - <p className="italic text-secondary">{record.description}</p> 72 - ) : null} 73 - 74 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 75 - {profile ? ( 76 - <> 77 - <a 78 - className="text-tertiary" 79 - href={`https://bsky.app/profile/${profile.handle}`} 80 - > 81 - by {profile.displayName || profile.handle} 82 - </a> 83 - </> 84 - ) : null} 85 - {record.publishedAt ? ( 86 - <> 87 - |<p>{formattedDate}</p> 88 - </> 89 - ) : null} 90 - |{" "} 68 + </> 69 + } 70 + postTitle={record.title} 71 + postDescription={record.description} 72 + postInfo={ 73 + <> 74 + <div className="flex flex-row gap-2 items-center"> 75 + {profile ? ( 76 + <ProfilePopover 77 + didOrHandle={profile.did} 78 + trigger={ 79 + <span className="text-tertiary hover:underline"> 80 + {profile.displayName || profile.handle} 81 + </span> 82 + } 83 + /> 84 + ) : null} 85 + {record.publishedAt ? ( 86 + <> 87 + <Separator classname="h-4!" /> 88 + <p>{formattedDate}</p> 89 + </> 90 + ) : null} 91 + </div> 91 92 <Interactions 92 93 showComments={props.preferences.showComments} 93 - compact 94 + showMentions={props.preferences.showMentions} 94 95 quotesCount={getQuoteCount(document) || 0} 95 96 commentsCount={getCommentCount(document) || 0} 96 97 /> 98 + </> 99 + } 100 + /> 101 + ); 102 + } 103 + 104 + export const PostHeaderLayout = (props: { 105 + pubLink: React.ReactNode; 106 + postTitle: React.ReactNode | undefined; 107 + postDescription: React.ReactNode | undefined; 108 + postInfo: React.ReactNode; 109 + }) => { 110 + return ( 111 + <div 112 + className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 113 + id="post-header" 114 + > 115 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 116 + {props.pubLink} 117 + </div> 118 + <h2 119 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 120 + > 121 + {props.postTitle ? props.postTitle : "Untitled"} 122 + </h2> 123 + {props.postDescription ? ( 124 + <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 125 + {props.postDescription} 97 126 </div> 127 + ) : null} 128 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 129 + {props.postInfo} 98 130 </div> 99 131 </div> 100 132 ); 101 - } 133 + };
+118
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 1 + "use client"; 2 + import { AppBskyFeedDefs } from "@atproto/api"; 3 + import { preload } from "swr"; 4 + import { openPage, OpenPage } from "./PostPages"; 5 + 6 + type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 7 + type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 8 + type BlockedPost = AppBskyFeedDefs.BlockedPost; 9 + type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 10 + 11 + type PostView = AppBskyFeedDefs.PostView; 12 + 13 + export interface QuotesResponse { 14 + uri: string; 15 + cid?: string; 16 + cursor?: string; 17 + posts: PostView[]; 18 + } 19 + 20 + // Thread fetching 21 + export const getThreadKey = (uri: string) => `thread:${uri}`; 22 + 23 + export async function fetchThread(uri: string): Promise<ThreadType> { 24 + const params = new URLSearchParams({ uri }); 25 + const response = await fetch(`/api/bsky/thread?${params.toString()}`); 26 + 27 + if (!response.ok) { 28 + throw new Error("Failed to fetch thread"); 29 + } 30 + 31 + return response.json(); 32 + } 33 + 34 + export const prefetchThread = (uri: string) => { 35 + preload(getThreadKey(uri), () => fetchThread(uri)); 36 + }; 37 + 38 + // Quotes fetching 39 + export const getQuotesKey = (uri: string) => `quotes:${uri}`; 40 + 41 + export async function fetchQuotes(uri: string): Promise<QuotesResponse> { 42 + const params = new URLSearchParams({ uri }); 43 + const response = await fetch(`/api/bsky/quotes?${params.toString()}`); 44 + 45 + if (!response.ok) { 46 + throw new Error("Failed to fetch quotes"); 47 + } 48 + 49 + return response.json(); 50 + } 51 + 52 + export const prefetchQuotes = (uri: string) => { 53 + preload(getQuotesKey(uri), () => fetchQuotes(uri)); 54 + }; 55 + 56 + // Link component for opening thread pages with prefetching 57 + export function ThreadLink(props: { 58 + threadUri: string; 59 + parent?: OpenPage; 60 + children: React.ReactNode; 61 + className?: string; 62 + onClick?: (e: React.MouseEvent) => void; 63 + }) { 64 + const { threadUri, parent, children, className, onClick } = props; 65 + 66 + const handleClick = (e: React.MouseEvent) => { 67 + onClick?.(e); 68 + if (e.defaultPrevented) return; 69 + openPage(parent, { type: "thread", uri: threadUri }); 70 + }; 71 + 72 + const handlePrefetch = () => { 73 + prefetchThread(threadUri); 74 + }; 75 + 76 + return ( 77 + <button 78 + className={className} 79 + onClick={handleClick} 80 + onMouseEnter={handlePrefetch} 81 + onPointerDown={handlePrefetch} 82 + > 83 + {children} 84 + </button> 85 + ); 86 + } 87 + 88 + // Link component for opening quotes pages with prefetching 89 + export function QuotesLink(props: { 90 + postUri: string; 91 + parent?: OpenPage; 92 + children: React.ReactNode; 93 + className?: string; 94 + onClick?: (e: React.MouseEvent) => void; 95 + }) { 96 + const { postUri, parent, children, className, onClick } = props; 97 + 98 + const handleClick = (e: React.MouseEvent) => { 99 + onClick?.(e); 100 + if (e.defaultPrevented) return; 101 + openPage(parent, { type: "quotes", uri: postUri }); 102 + }; 103 + 104 + const handlePrefetch = () => { 105 + prefetchQuotes(postUri); 106 + }; 107 + 108 + return ( 109 + <button 110 + className={className} 111 + onClick={handleClick} 112 + onMouseEnter={handlePrefetch} 113 + onPointerDown={handlePrefetch} 114 + > 115 + {children} 116 + </button> 117 + ); 118 + }
+130 -28
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 19 19 import { Fragment, useEffect } from "react"; 20 20 import { flushSync } from "react-dom"; 21 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 - import { useParams } from "next/navigation"; 22 + import { useParams, useSearchParams } from "next/navigation"; 23 23 import { decodeQuotePosition } from "./quotePosition"; 24 24 import { PollData } from "./fetchPollData"; 25 25 import { LinearDocumentPage } from "./LinearDocumentPage"; 26 26 import { CanvasPage } from "./CanvasPage"; 27 + import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; 28 + import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; 29 + 30 + // Page types 31 + export type DocPage = { type: "doc"; id: string }; 32 + export type ThreadPage = { type: "thread"; uri: string }; 33 + export type QuotesPage = { type: "quotes"; uri: string }; 34 + export type OpenPage = DocPage | ThreadPage | QuotesPage; 35 + 36 + // Get a stable key for a page 37 + const getPageKey = (page: OpenPage): string => { 38 + if (page.type === "doc") return page.id; 39 + if (page.type === "quotes") return `quotes:${page.uri}`; 40 + return `thread:${page.uri}`; 41 + }; 27 42 28 43 const usePostPageUIState = create(() => ({ 29 - pages: [] as string[], 44 + pages: [] as OpenPage[], 30 45 initialized: false, 31 46 })); 32 47 33 - export const useOpenPages = () => { 48 + export const useOpenPages = (): OpenPage[] => { 34 49 const { quote } = useParams(); 35 50 const state = usePostPageUIState((s) => s); 51 + const searchParams = useSearchParams(); 52 + const pageParam = searchParams.get("page"); 36 53 37 - if (!state.initialized && quote) { 38 - const decodedQuote = decodeQuotePosition(quote as string); 39 - if (decodedQuote?.pageId) { 40 - return [decodedQuote.pageId]; 54 + if (!state.initialized) { 55 + // Check for page search param first (for comment links) 56 + if (pageParam) { 57 + return [{ type: "doc", id: pageParam }]; 58 + } 59 + // Then check for quote param 60 + if (quote) { 61 + const decodedQuote = decodeQuotePosition(quote as string); 62 + if (decodedQuote?.pageId) { 63 + return [{ type: "doc", id: decodedQuote.pageId }]; 64 + } 41 65 } 42 66 } 43 67 ··· 46 70 47 71 export const useInitializeOpenPages = () => { 48 72 const { quote } = useParams(); 73 + const searchParams = useSearchParams(); 74 + const pageParam = searchParams.get("page"); 49 75 50 76 useEffect(() => { 51 77 const state = usePostPageUIState.getState(); 52 78 if (!state.initialized) { 79 + // Check for page search param first (for comment links) 80 + if (pageParam) { 81 + usePostPageUIState.setState({ 82 + pages: [{ type: "doc", id: pageParam }], 83 + initialized: true, 84 + }); 85 + return; 86 + } 87 + // Then check for quote param 53 88 if (quote) { 54 89 const decodedQuote = decodeQuotePosition(quote as string); 55 90 if (decodedQuote?.pageId) { 56 91 usePostPageUIState.setState({ 57 - pages: [decodedQuote.pageId], 92 + pages: [{ type: "doc", id: decodedQuote.pageId }], 58 93 initialized: true, 59 94 }); 60 95 return; ··· 63 98 // Mark as initialized even if no pageId found 64 99 usePostPageUIState.setState({ initialized: true }); 65 100 } 66 - }, [quote]); 101 + }, [quote, pageParam]); 67 102 }; 68 103 69 104 export const openPage = ( 70 - parent: string | undefined, 71 - page: string, 105 + parent: OpenPage | undefined, 106 + page: OpenPage, 72 107 options?: { scrollIntoView?: boolean }, 73 108 ) => { 109 + const pageKey = getPageKey(page); 110 + const parentKey = parent ? getPageKey(parent) : undefined; 111 + 74 112 flushSync(() => { 75 113 usePostPageUIState.setState((state) => { 76 - let parentPosition = state.pages.findIndex((s) => s == parent); 114 + let parentPosition = state.pages.findIndex( 115 + (s) => getPageKey(s) === parentKey, 116 + ); 77 117 return { 78 118 pages: 79 119 parentPosition === -1 ··· 85 125 }); 86 126 87 127 if (options?.scrollIntoView !== false) { 88 - scrollIntoView(`post-page-${page}`); 128 + scrollIntoView(`post-page-${pageKey}`); 89 129 } 90 130 }; 91 131 92 - export const closePage = (page: string) => 132 + export const closePage = (page: OpenPage) => { 133 + const pageKey = getPageKey(page); 93 134 usePostPageUIState.setState((state) => { 94 - let parentPosition = state.pages.findIndex((s) => s == page); 135 + let parentPosition = state.pages.findIndex( 136 + (s) => getPageKey(s) === pageKey, 137 + ); 95 138 return { 96 139 pages: state.pages.slice(0, parentPosition), 97 140 initialized: true, 98 141 }; 99 142 }); 143 + }; 100 144 101 145 // Shared props type for both page components 102 146 export type SharedPageProps = { 103 147 document: PostPageData; 104 148 did: string; 105 149 profile: ProfileViewDetailed; 106 - preferences: { showComments?: boolean }; 150 + preferences: { 151 + showComments?: boolean; 152 + showMentions?: boolean; 153 + showPrevNext?: boolean; 154 + }; 107 155 pubRecord?: PubLeafletPublication.Record; 108 156 theme?: PubLeafletPublication.Theme | null; 109 157 prerenderedCodeBlocks?: Map<string, string>; ··· 162 210 did: string; 163 211 prerenderedCodeBlocks?: Map<string, string>; 164 212 bskyPostData: AppBskyFeedDefs.PostView[]; 165 - preferences: { showComments?: boolean }; 213 + preferences: { 214 + showComments?: boolean; 215 + showMentions?: boolean; 216 + showPrevNext?: boolean; 217 + }; 166 218 pollData: PollData[]; 167 219 }) { 168 220 let drawer = useDrawerOpen(document_uri); ··· 217 269 218 270 {drawer && !drawer.pageId && ( 219 271 <InteractionDrawer 272 + showPageBackground={pubRecord?.theme?.showPageBackground} 220 273 document_uri={document.uri} 221 274 comments={ 222 275 pubRecord?.preferences?.showComments === false 223 276 ? [] 224 277 : document.comments_on_documents 225 278 } 226 - quotesAndMentions={quotesAndMentions} 279 + quotesAndMentions={ 280 + pubRecord?.preferences?.showMentions === false 281 + ? [] 282 + : quotesAndMentions 283 + } 227 284 did={did} 228 285 /> 229 286 )} 230 287 231 - {openPageIds.map((pageId) => { 288 + {openPageIds.map((openPage) => { 289 + const pageKey = getPageKey(openPage); 290 + 291 + // Handle thread pages 292 + if (openPage.type === "thread") { 293 + return ( 294 + <Fragment key={pageKey}> 295 + <SandwichSpacer /> 296 + <ThreadPageComponent 297 + threadUri={openPage.uri} 298 + pageId={pageKey} 299 + hasPageBackground={hasPageBackground} 300 + pageOptions={ 301 + <PageOptions 302 + onClick={() => closePage(openPage)} 303 + hasPageBackground={hasPageBackground} 304 + /> 305 + } 306 + /> 307 + </Fragment> 308 + ); 309 + } 310 + 311 + // Handle quotes pages 312 + if (openPage.type === "quotes") { 313 + return ( 314 + <Fragment key={pageKey}> 315 + <SandwichSpacer /> 316 + <BlueskyQuotesPage 317 + postUri={openPage.uri} 318 + pageId={pageKey} 319 + hasPageBackground={hasPageBackground} 320 + pageOptions={ 321 + <PageOptions 322 + onClick={() => closePage(openPage)} 323 + hasPageBackground={hasPageBackground} 324 + /> 325 + } 326 + /> 327 + </Fragment> 328 + ); 329 + } 330 + 331 + // Handle document pages 232 332 let page = record.pages.find( 233 333 (p) => 234 334 ( 235 335 p as 236 336 | PubLeafletPagesLinearDocument.Main 237 337 | PubLeafletPagesCanvas.Main 238 - ).id === pageId, 338 + ).id === openPage.id, 239 339 ) as 240 340 | PubLeafletPagesLinearDocument.Main 241 341 | PubLeafletPagesCanvas.Main ··· 244 344 if (!page) return null; 245 345 246 346 return ( 247 - <Fragment key={pageId}> 347 + <Fragment key={pageKey}> 248 348 <SandwichSpacer /> 249 349 <PageRenderer 250 350 page={page} ··· 253 353 pageId={page.id} 254 354 pageOptions={ 255 355 <PageOptions 256 - onClick={() => closePage(page.id!)} 356 + onClick={() => closePage(openPage)} 257 357 hasPageBackground={hasPageBackground} 258 358 /> 259 359 } 260 360 /> 261 361 {drawer && drawer.pageId === page.id && ( 262 362 <InteractionDrawer 363 + showPageBackground={pubRecord?.theme?.showPageBackground} 263 364 pageId={page.id} 264 365 document_uri={document.uri} 265 366 comments={ ··· 267 368 ? [] 268 369 : document.comments_on_documents 269 370 } 270 - quotesAndMentions={quotesAndMentions} 371 + quotesAndMentions={ 372 + pubRecord?.preferences?.showMentions === false 373 + ? [] 374 + : quotesAndMentions 375 + } 271 376 did={did} 272 377 /> 273 378 )} ··· 287 392 return ( 288 393 <div 289 394 className={`pageOptions w-fit z-10 290 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 395 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 291 396 flex sm:flex-col flex-row-reverse gap-1 items-start`} 292 397 > 293 - <PageOptionButton 294 - cardBorderHidden={!props.hasPageBackground} 295 - onClick={props.onClick} 296 - > 398 + <PageOptionButton onClick={props.onClick}> 297 399 <CloseTiny /> 298 400 </PageOptionButton> 299 401 </div>
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 + "use client"; 2 + import { PubLeafletDocument } from "lexicons/api"; 3 + import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { AtUri } from "@atproto/api"; 6 + import { useParams } from "next/navigation"; 7 + import { getPostPageData } from "./getPostPageData"; 8 + import { PostPageContext } from "./PostPageContext"; 9 + import { useContext } from "react"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 + 13 + export const PostPrevNextButtons = (props: { 14 + showPrevNext: boolean | undefined; 15 + }) => { 16 + let postData = useContext(PostPageContext); 17 + let pub = postData?.documents_in_publications[0]?.publications; 18 + 19 + if (!props.showPrevNext || !pub || !postData) return; 20 + 21 + function getPostLink(uri: string) { 22 + return pub && uri 23 + ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 24 + : "leaflet.pub/not-found"; 25 + } 26 + let prevPost = postData?.prevNext?.prev; 27 + let nextPost = postData?.prevNext?.next; 28 + 29 + return ( 30 + <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2"> 31 + {/*<hr className="border-border-light" />*/} 32 + <div className="flex justify-between w-full gap-8 "> 33 + {nextPost ? ( 34 + <SpeedyLink 35 + href={getPostLink(nextPost.uri)} 36 + className="flex gap-1 items-center truncate min-w-0 basis-1/2" 37 + > 38 + <ArrowRightTiny className="rotate-180 shrink-0" /> 39 + <div className="min-w-0 truncate">{nextPost.title}</div> 40 + </SpeedyLink> 41 + ) : ( 42 + <div /> 43 + )} 44 + {prevPost ? ( 45 + <SpeedyLink 46 + href={getPostLink(prevPost.uri)} 47 + className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end" 48 + > 49 + <div className="min-w-0 truncate">{prevPost.title}</div> 50 + <ArrowRightTiny className="shrink-0" /> 51 + </SpeedyLink> 52 + ) : ( 53 + <div /> 54 + )} 55 + </div> 56 + </div> 57 + ); 58 + };
+48 -18
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 1 1 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 - import { useEntitySetContext } from "components/EntitySetProvider"; 3 - import { useEffect, useState } from "react"; 4 - import { useEntity } from "src/replicache"; 5 - import { useUIState } from "src/useUIState"; 6 - import { elementId } from "src/utils/elementId"; 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 9 3 import { Separator } from "components/Layout"; 10 4 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 12 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "./PostLinks"; 13 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 14 10 import { 15 11 BlueskyEmbed, 16 12 PostNotAvailable, 17 13 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 18 14 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 + import { openPage } from "./PostPages"; 19 16 20 17 export const PubBlueskyPostBlock = (props: { 21 18 post: PostView; 22 19 className: string; 20 + pageId?: string; 23 21 }) => { 24 22 let post = props.post; 23 + 24 + const handleOpenThread = () => { 25 + openPage( 26 + props.pageId ? { type: "doc", id: props.pageId } : undefined, 27 + { type: "thread", uri: post.uri }, 28 + ); 29 + }; 30 + 25 31 switch (true) { 26 32 case AppBskyFeedDefs.isBlockedPost(post) || 27 33 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 34 40 35 41 case AppBskyFeedDefs.validatePostView(post).success: 36 42 let record = post.record as AppBskyFeedDefs.PostView["record"]; 37 - let facets = record.facets; 38 43 39 44 // silliness to get the text and timestamp from the record with proper types 40 - let text: string | null = null; 41 45 let timestamp: string | undefined = undefined; 42 46 if (AppBskyFeedPost.isRecord(record)) { 43 - text = (record as AppBskyFeedPost.Record).text; 44 47 timestamp = (record as AppBskyFeedPost.Record).createdAt; 45 48 } 46 49 47 50 //getting the url to the post 48 51 let postId = post.uri.split("/")[4]; 49 52 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 + 54 + const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; 50 55 51 56 return ( 52 57 <div 58 + onClick={handleOpenThread} 53 59 className={` 54 60 ${props.className} 55 61 block-border 56 62 mb-2 57 63 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 64 + cursor-pointer hover:border-accent-contrast 58 65 `} 59 66 > 60 67 {post.author && record && ( ··· 75 82 className="text-xs text-tertiary hover:underline" 76 83 target="_blank" 77 84 href={`https://bsky.app/profile/${post.author?.handle}`} 85 + onClick={(e) => e.stopPropagation()} 78 86 > 79 87 @{post.author?.handle} 80 88 </a> ··· 90 98 </pre> 91 99 </div> 92 100 {post.embed && ( 93 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 101 + <div onClick={(e) => e.stopPropagation()}> 102 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 103 + </div> 94 104 )} 95 105 </div> 96 106 </> ··· 98 108 <div className="w-full flex gap-2 items-center justify-between"> 99 109 <ClientDate date={timestamp} /> 100 110 <div className="flex gap-2 items-center"> 101 - {post.replyCount && post.replyCount > 0 && ( 111 + {post.replyCount != null && post.replyCount > 0 && ( 102 112 <> 103 - <a 104 - className="flex items-center gap-1 hover:no-underline" 105 - target="_blank" 106 - href={url} 113 + <ThreadLink 114 + threadUri={post.uri} 115 + parent={parent} 116 + className="flex items-center gap-1 hover:text-accent-contrast" 117 + onClick={(e) => e.stopPropagation()} 107 118 > 108 119 {post.replyCount} 109 120 <CommentTiny /> 110 - </a> 121 + </ThreadLink> 122 + <Separator classname="h-4" /> 123 + </> 124 + )} 125 + {post.quoteCount != null && post.quoteCount > 0 && ( 126 + <> 127 + <QuotesLink 128 + postUri={post.uri} 129 + parent={parent} 130 + className="flex items-center gap-1 hover:text-accent-contrast" 131 + onClick={(e) => e.stopPropagation()} 132 + > 133 + {post.quoteCount} 134 + <QuoteTiny /> 135 + </QuotesLink> 111 136 <Separator classname="h-4" /> 112 137 </> 113 138 )} 114 139 115 - <a className="" target="_blank" href={url}> 140 + <a 141 + className="" 142 + target="_blank" 143 + href={url} 144 + onClick={(e) => e.stopPropagation()} 145 + > 116 146 <BlueskyTiny /> 117 147 </a> 118 148 </div>
+17 -8
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 40 40 }) { 41 41 //switch to use actually state 42 42 let openPages = useOpenPages(); 43 - let isOpen = openPages.includes(props.pageId); 43 + let isOpen = openPages.some( 44 + (p) => p.type === "doc" && p.id === props.pageId, 45 + ); 44 46 return ( 45 47 <div 46 48 className={`w-full cursor-pointer ··· 57 59 e.preventDefault(); 58 60 e.stopPropagation(); 59 61 60 - openPage(props.parentPageId, props.pageId); 62 + openPage( 63 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 64 + { type: "doc", id: props.pageId }, 65 + ); 61 66 }} 62 67 > 63 68 {props.isCanvas ? ( ··· 213 218 onClick={(e) => { 214 219 e.preventDefault(); 215 220 e.stopPropagation(); 216 - openPage(props.parentPageId, props.pageId, { 217 - scrollIntoView: false, 218 - }); 221 + openPage( 222 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 223 + { type: "doc", id: props.pageId }, 224 + { scrollIntoView: false }, 225 + ); 219 226 if (!drawerOpen || drawer !== "quotes") 220 227 openInteractionDrawer("quotes", document_uri, props.pageId); 221 228 else setInteractionState(document_uri, { drawerOpen: false }); ··· 231 238 onClick={(e) => { 232 239 e.preventDefault(); 233 240 e.stopPropagation(); 234 - openPage(props.parentPageId, props.pageId, { 235 - scrollIntoView: false, 236 - }); 241 + openPage( 242 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 243 + { type: "doc", id: props.pageId }, 244 + { scrollIntoView: false }, 245 + ); 237 246 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 238 247 openInteractionDrawer("comments", document_uri, props.pageId); 239 248 else setInteractionState(document_uri, { drawerOpen: false });
+3 -2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 186 186 <BlueskyLinkTiny className="shrink-0" /> 187 187 Bluesky 188 188 </a> 189 - <Separator classname="h-4" /> 189 + <Separator classname="h-4!" /> 190 190 <button 191 191 id="copy-quote-link" 192 192 className="flex gap-1 items-center hover:font-bold px-1" ··· 211 211 </button> 212 212 {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 213 213 <> 214 - <Separator classname="h-4" /> 214 + <Separator classname="h-4! " /> 215 + 215 216 <button 216 217 className="flex gap-1 items-center hover:font-bold px-1" 217 218 onClick={() => {
+11 -7
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 - import { BaseTextBlock } from "./BaseTextBlock"; 15 + import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore"; 16 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 + 19 + function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 20 + return <TextBlockCore {...props} />; 21 + } 18 22 19 23 export function StaticPostContent({ 20 24 blocks, ··· 47 51 case PubLeafletBlocksBlockquote.isMain(b.block): { 48 52 return ( 49 53 <blockquote className={` blockquote `}> 50 - <BaseTextBlock 54 + <StaticBaseTextBlock 51 55 facets={b.block.facets} 52 56 plaintext={b.block.plaintext} 53 57 index={[]} ··· 116 120 case PubLeafletBlocksText.isMain(b.block): 117 121 return ( 118 122 <p> 119 - <BaseTextBlock 123 + <StaticBaseTextBlock 120 124 facets={b.block.facets} 121 125 plaintext={b.block.plaintext} 122 126 index={[]} ··· 127 131 if (b.block.level === 1) 128 132 return ( 129 133 <h1> 130 - <BaseTextBlock {...b.block} index={[]} /> 134 + <StaticBaseTextBlock {...b.block} index={[]} /> 131 135 </h1> 132 136 ); 133 137 if (b.block.level === 2) 134 138 return ( 135 139 <h2> 136 - <BaseTextBlock {...b.block} index={[]} /> 140 + <StaticBaseTextBlock {...b.block} index={[]} /> 137 141 </h2> 138 142 ); 139 143 if (b.block.level === 3) 140 144 return ( 141 145 <h3> 142 - <BaseTextBlock {...b.block} index={[]} /> 146 + <StaticBaseTextBlock {...b.block} index={[]} /> 143 147 </h3> 144 148 ); 145 149 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 146 150 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 147 151 return ( 148 152 <h6> 149 - <BaseTextBlock {...b.block} index={[]} /> 153 + <StaticBaseTextBlock {...b.block} index={[]} /> 150 154 </h6> 151 155 ); 152 156 }
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { AtMentionLink } from "components/AtMentionLink"; 4 + import { ReactNode } from "react"; 5 + 6 + type Facet = PubLeafletRichtextFacet.Main; 7 + 8 + export type FacetRenderers = { 9 + DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 + }; 11 + 12 + export type TextBlockCoreProps = { 13 + plaintext: string; 14 + facets?: Facet[]; 15 + index: number[]; 16 + preview?: boolean; 17 + renderers?: FacetRenderers; 18 + }; 19 + 20 + export function TextBlockCore(props: TextBlockCoreProps) { 21 + let children = []; 22 + let richText = new RichText({ 23 + text: props.plaintext, 24 + facets: props.facets || [], 25 + }); 26 + let counter = 0; 27 + for (const segment of richText.segments()) { 28 + let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 + let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 + let isStrikethrough = segment.facet?.find( 33 + PubLeafletRichtextFacet.isStrikethrough, 34 + ); 35 + let isDidMention = segment.facet?.find( 36 + PubLeafletRichtextFacet.isDidMention, 37 + ); 38 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 + let isHighlighted = segment.facet?.find( 42 + PubLeafletRichtextFacet.isHighlight, 43 + ); 44 + let className = ` 45 + ${isCode ? "inline-code" : ""} 46 + ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 + ${isBold ? "font-bold" : ""} 48 + ${isItalic ? "italic" : ""} 49 + ${isUnderline ? "underline" : ""} 50 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 + 53 + // Split text by newlines and insert <br> tags 54 + const textParts = segment.text.split("\n"); 55 + const renderedText = textParts.flatMap((part, i) => 56 + i < textParts.length - 1 57 + ? [part, <br key={`br-${counter}-${i}`} />] 58 + : [part], 59 + ); 60 + 61 + if (isCode) { 62 + children.push( 63 + <code key={counter} className={className} id={id?.id}> 64 + {renderedText} 65 + </code>, 66 + ); 67 + } else if (isDidMention) { 68 + const DidMentionRenderer = props.renderers?.DidMention; 69 + if (DidMentionRenderer) { 70 + children.push( 71 + <DidMentionRenderer key={counter} did={isDidMention.did}> 72 + <span className="mention">{renderedText}</span> 73 + </DidMentionRenderer>, 74 + ); 75 + } else { 76 + // Default: render as a simple link 77 + children.push( 78 + <a 79 + key={counter} 80 + href={`https://leaflet.pub/p/${isDidMention.did}`} 81 + target="_blank" 82 + className="no-underline" 83 + > 84 + <span className="mention">{renderedText}</span> 85 + </a>, 86 + ); 87 + } 88 + } else if (isAtMention) { 89 + children.push( 90 + <AtMentionLink 91 + key={counter} 92 + atURI={isAtMention.atURI} 93 + className={className} 94 + > 95 + {renderedText} 96 + </AtMentionLink>, 97 + ); 98 + } else if (link) { 99 + children.push( 100 + <a 101 + key={counter} 102 + href={link.uri.trim()} 103 + className={`text-accent-contrast hover:underline ${className}`} 104 + target="_blank" 105 + > 106 + {renderedText} 107 + </a>, 108 + ); 109 + } else { 110 + children.push( 111 + <span key={counter} className={className} id={id?.id}> 112 + {renderedText} 113 + </span>, 114 + ); 115 + } 116 + 117 + counter++; 118 + } 119 + return <>{children}</>; 120 + } 121 + 122 + type RichTextSegment = { 123 + text: string; 124 + facet?: Exclude<Facet["features"], { $type: string }>; 125 + }; 126 + 127 + export class RichText { 128 + unicodeText: UnicodeString; 129 + facets?: Facet[]; 130 + 131 + constructor(props: { text: string; facets: Facet[] }) { 132 + this.unicodeText = new UnicodeString(props.text); 133 + this.facets = props.facets; 134 + if (this.facets) { 135 + this.facets = this.facets 136 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 + } 139 + } 140 + 141 + *segments(): Generator<RichTextSegment, void, void> { 142 + const facets = this.facets || []; 143 + if (!facets.length) { 144 + yield { text: this.unicodeText.utf16 }; 145 + return; 146 + } 147 + 148 + let textCursor = 0; 149 + let facetCursor = 0; 150 + do { 151 + const currFacet = facets[facetCursor]; 152 + if (textCursor < currFacet.index.byteStart) { 153 + yield { 154 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 + }; 156 + } else if (textCursor > currFacet.index.byteStart) { 157 + facetCursor++; 158 + continue; 159 + } 160 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 + const subtext = this.unicodeText.slice( 162 + currFacet.index.byteStart, 163 + currFacet.index.byteEnd, 164 + ); 165 + if (!subtext.trim()) { 166 + // dont empty string entities 167 + yield { text: subtext }; 168 + } else { 169 + yield { text: subtext, facet: currFacet.features }; 170 + } 171 + } 172 + textCursor = currFacet.index.byteEnd; 173 + facetCursor++; 174 + } while (facetCursor < facets.length); 175 + if (textCursor < this.unicodeText.length) { 176 + yield { 177 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 + }; 179 + } 180 + } 181 + }
+324
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 + "use client"; 2 + import { useEffect, useRef } from "react"; 3 + import { AppBskyFeedDefs } from "@atproto/api"; 4 + import useSWR from "swr"; 5 + import { PageWrapper } from "components/Pages/Page"; 6 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 7 + import { DotLoader } from "components/utils/DotLoader"; 8 + import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 + import { openPage } from "./PostPages"; 10 + import { useThreadState } from "src/useThreadState"; 11 + import { BskyPostContent, ClientDate } from "./BskyPostContent"; 12 + import { 13 + ThreadLink, 14 + getThreadKey, 15 + fetchThread, 16 + prefetchThread, 17 + } from "./PostLinks"; 18 + 19 + // Re-export for backwards compatibility 20 + export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; 21 + 22 + type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 23 + type NotFoundPost = AppBskyFeedDefs.NotFoundPost; 24 + type BlockedPost = AppBskyFeedDefs.BlockedPost; 25 + type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 26 + 27 + export function ThreadPage(props: { 28 + threadUri: string; 29 + pageId: string; 30 + pageOptions?: React.ReactNode; 31 + hasPageBackground: boolean; 32 + }) { 33 + const { threadUri, pageId, pageOptions } = props; 34 + const drawer = useDrawerOpen(threadUri); 35 + 36 + const { 37 + data: thread, 38 + isLoading, 39 + error, 40 + } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 41 + fetchThread(threadUri), 42 + ); 43 + 44 + return ( 45 + <PageWrapper 46 + pageType="doc" 47 + fullPageScroll={false} 48 + id={`post-page-${pageId}`} 49 + drawerOpen={!!drawer} 50 + pageOptions={pageOptions} 51 + > 52 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 53 + {isLoading ? ( 54 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 55 + <span>loading thread</span> 56 + <DotLoader /> 57 + </div> 58 + ) : error ? ( 59 + <div className="text-tertiary italic text-sm text-center py-8"> 60 + Failed to load thread 61 + </div> 62 + ) : thread ? ( 63 + <ThreadContent thread={thread} threadUri={threadUri} /> 64 + ) : null} 65 + </div> 66 + </PageWrapper> 67 + ); 68 + } 69 + 70 + function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 71 + const { thread, threadUri } = props; 72 + const mainPostRef = useRef<HTMLDivElement>(null); 73 + 74 + // Scroll the main post into view when the thread loads 75 + useEffect(() => { 76 + if (mainPostRef.current) { 77 + mainPostRef.current.scrollIntoView({ 78 + behavior: "instant", 79 + block: "start", 80 + }); 81 + } 82 + }, []); 83 + 84 + if (AppBskyFeedDefs.isNotFoundPost(thread)) { 85 + return <PostNotAvailable />; 86 + } 87 + 88 + if (AppBskyFeedDefs.isBlockedPost(thread)) { 89 + return ( 90 + <div className="text-tertiary italic text-sm text-center py-8"> 91 + This post is blocked 92 + </div> 93 + ); 94 + } 95 + 96 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 97 + return <PostNotAvailable />; 98 + } 99 + 100 + // Collect all parent posts in order (oldest first) 101 + const parents: ThreadViewPost[] = []; 102 + let currentParent = thread.parent; 103 + while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 104 + parents.unshift(currentParent); 105 + currentParent = currentParent.parent; 106 + } 107 + 108 + return ( 109 + <div className="flex flex-col gap-0"> 110 + {/* Parent posts */} 111 + {parents.map((parent, index) => ( 112 + <div key={parent.post.uri} className="flex flex-col"> 113 + <ThreadPost 114 + post={parent} 115 + isMainPost={false} 116 + showReplyLine={index < parents.length - 1 || true} 117 + threadUri={threadUri} 118 + /> 119 + </div> 120 + ))} 121 + 122 + {/* Main post */} 123 + <div ref={mainPostRef}> 124 + <ThreadPost 125 + post={thread} 126 + isMainPost={true} 127 + showReplyLine={false} 128 + threadUri={threadUri} 129 + /> 130 + </div> 131 + 132 + {/* Replies */} 133 + {thread.replies && thread.replies.length > 0 && ( 134 + <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 135 + <div className="text-tertiary text-xs font-bold mb-2 px-2"> 136 + Replies 137 + </div> 138 + <Replies 139 + replies={thread.replies as any[]} 140 + threadUri={threadUri} 141 + depth={0} 142 + parentAuthorDid={thread.post.author.did} 143 + /> 144 + </div> 145 + )} 146 + </div> 147 + ); 148 + } 149 + 150 + function ThreadPost(props: { 151 + post: ThreadViewPost; 152 + isMainPost: boolean; 153 + showReplyLine: boolean; 154 + threadUri: string; 155 + }) { 156 + const { post, isMainPost, showReplyLine, threadUri } = props; 157 + const postView = post.post; 158 + const parent = { type: "thread" as const, uri: threadUri }; 159 + 160 + return ( 161 + <div className="flex gap-2 relative"> 162 + {/* Reply line connector */} 163 + {showReplyLine && ( 164 + <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 + )} 166 + 167 + <BskyPostContent 168 + post={postView} 169 + parent={parent} 170 + linksEnabled={!isMainPost} 171 + showBlueskyLink={true} 172 + showEmbed={true} 173 + /> 174 + </div> 175 + ); 176 + } 177 + 178 + function Replies(props: { 179 + replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 180 + threadUri: string; 181 + depth: number; 182 + parentAuthorDid?: string; 183 + }) { 184 + const { replies, threadUri, depth, parentAuthorDid } = props; 185 + const collapsedThreads = useThreadState((s) => s.collapsedThreads); 186 + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 187 + 188 + // Sort replies so that replies from the parent author come first 189 + const sortedReplies = parentAuthorDid 190 + ? [...replies].sort((a, b) => { 191 + const aIsAuthor = 192 + AppBskyFeedDefs.isThreadViewPost(a) && 193 + a.post.author.did === parentAuthorDid; 194 + const bIsAuthor = 195 + AppBskyFeedDefs.isThreadViewPost(b) && 196 + b.post.author.did === parentAuthorDid; 197 + if (aIsAuthor && !bIsAuthor) return -1; 198 + if (!aIsAuthor && bIsAuthor) return 1; 199 + return 0; 200 + }) 201 + : replies; 202 + 203 + return ( 204 + <div className="flex flex-col gap-0"> 205 + {sortedReplies.map((reply, index) => { 206 + if (AppBskyFeedDefs.isNotFoundPost(reply)) { 207 + return ( 208 + <div 209 + key={`not-found-${index}`} 210 + className="text-tertiary italic text-xs py-2 px-2" 211 + > 212 + Post not found 213 + </div> 214 + ); 215 + } 216 + 217 + if (AppBskyFeedDefs.isBlockedPost(reply)) { 218 + return ( 219 + <div 220 + key={`blocked-${index}`} 221 + className="text-tertiary italic text-xs py-2 px-2" 222 + > 223 + Post blocked 224 + </div> 225 + ); 226 + } 227 + 228 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) { 229 + return null; 230 + } 231 + 232 + const hasReplies = reply.replies && reply.replies.length > 0; 233 + const isCollapsed = collapsedThreads.has(reply.post.uri); 234 + const replyCount = reply.replies?.length ?? 0; 235 + 236 + return ( 237 + <div key={reply.post.uri} className="flex flex-col"> 238 + <ReplyPost 239 + post={reply} 240 + showReplyLine={hasReplies || index < replies.length - 1} 241 + isLast={index === replies.length - 1 && !hasReplies} 242 + threadUri={threadUri} 243 + /> 244 + {hasReplies && depth < 3 && ( 245 + <div className="ml-2 flex"> 246 + {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 247 + <button 248 + onClick={(e) => { 249 + e.stopPropagation(); 250 + toggleCollapsed(reply.post.uri); 251 + }} 252 + className="group w-8 flex justify-center cursor-pointer shrink-0" 253 + aria-label={ 254 + isCollapsed ? "Expand replies" : "Collapse replies" 255 + } 256 + > 257 + <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 258 + </button> 259 + {isCollapsed ? ( 260 + <button 261 + onClick={(e) => { 262 + e.stopPropagation(); 263 + toggleCollapsed(reply.post.uri); 264 + }} 265 + className="text-xs text-accent-contrast hover:underline py-1 pl-1" 266 + > 267 + Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 268 + </button> 269 + ) : ( 270 + <div className="grow"> 271 + <Replies 272 + replies={reply.replies as any[]} 273 + threadUri={threadUri} 274 + depth={depth + 1} 275 + parentAuthorDid={reply.post.author.did} 276 + /> 277 + </div> 278 + )} 279 + </div> 280 + )} 281 + {hasReplies && depth >= 3 && ( 282 + <ThreadLink 283 + threadUri={reply.post.uri} 284 + parent={{ type: "thread", uri: threadUri }} 285 + className="ml-12 text-xs text-accent-contrast hover:underline py-1" 286 + > 287 + View more replies 288 + </ThreadLink> 289 + )} 290 + </div> 291 + ); 292 + })} 293 + </div> 294 + ); 295 + } 296 + 297 + function ReplyPost(props: { 298 + post: ThreadViewPost; 299 + showReplyLine: boolean; 300 + isLast: boolean; 301 + threadUri: string; 302 + }) { 303 + const { post, threadUri } = props; 304 + const postView = post.post; 305 + const parent = { type: "thread" as const, uri: threadUri }; 306 + 307 + return ( 308 + <div 309 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 310 + onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 311 + > 312 + <BskyPostContent 313 + post={postView} 314 + parent={parent} 315 + linksEnabled={true} 316 + avatarSize="sm" 317 + showEmbed={false} 318 + showBlueskyLink={false} 319 + onLinkClick={(e) => e.stopPropagation()} 320 + onEmbedClick={(e) => e.stopPropagation()} 321 + /> 322 + </div> 323 + ); 324 + }
+58 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 10 10 data, 11 11 uri, 12 12 comments_on_documents(*, bsky_profiles(*)), 13 - documents_in_publications(publications(*, publication_subscriptions(*))), 13 + documents_in_publications(publications(*, 14 + documents_in_publications(documents(uri, data)), 15 + publication_subscriptions(*)) 16 + ), 14 17 document_mentions_in_bsky(*), 15 18 leaflets_in_publications(*) 16 19 `, ··· 51 54 ?.record as PubLeafletPublication.Record 52 55 )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 56 57 + // Calculate prev/next documents from the fetched publication documents 58 + let prevNext: 59 + | { 60 + prev?: { uri: string; title: string }; 61 + next?: { uri: string; title: string }; 62 + } 63 + | undefined; 64 + 65 + const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 + ?.publishedAt; 67 + const allDocs = 68 + document.documents_in_publications[0]?.publications 69 + ?.documents_in_publications; 70 + 71 + if (currentPublishedAt && allDocs) { 72 + // Filter and sort documents by publishedAt 73 + const sortedDocs = allDocs 74 + .map((dip) => ({ 75 + uri: dip?.documents?.uri, 76 + title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 + publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 + .publishedAt, 79 + })) 80 + .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 81 + .sort( 82 + (a, b) => 83 + new Date(a.publishedAt!).getTime() - 84 + new Date(b.publishedAt!).getTime(), 85 + ); 86 + 87 + // Find current document index 88 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 89 + 90 + if (currentIndex !== -1) { 91 + prevNext = { 92 + prev: 93 + currentIndex > 0 94 + ? { 95 + uri: sortedDocs[currentIndex - 1].uri || "", 96 + title: sortedDocs[currentIndex - 1].title, 97 + } 98 + : undefined, 99 + next: 100 + currentIndex < sortedDocs.length - 1 101 + ? { 102 + uri: sortedDocs[currentIndex + 1].uri || "", 103 + title: sortedDocs[currentIndex + 1].title, 104 + } 105 + : undefined, 106 + }; 107 + } 108 + } 109 + 54 110 return { 55 111 ...document, 56 112 quotesAndMentions, 57 113 theme, 114 + prevNext, 58 115 }; 59 116 } 60 117
+44 -1
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { PubLeafletDocument } from "lexicons/api"; 6 + import { jsonToLex } from "@atproto/lexicon"; 7 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 2 8 3 - export const runtime = "edge"; 4 9 export const revalidate = 60; 5 10 6 11 export default async function OpenGraphImage(props: { 7 12 params: Promise<{ publication: string; did: string; rkey: string }>; 8 13 }) { 9 14 let params = await props.params; 15 + let did = decodeURIComponent(params.did); 16 + 17 + // Try to get the document's cover image 18 + let { data: document } = await supabaseServerClient 19 + .from("documents") 20 + .select("data") 21 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 22 + .single(); 23 + 24 + if (document) { 25 + let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 26 + if (docRecord.coverImage) { 27 + try { 28 + // Get CID from the blob ref (handle both serialized and hydrated forms) 29 + let cid = 30 + (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || 31 + docRecord.coverImage.ref.toString(); 32 + 33 + let imageResponse = await fetchAtprotoBlob(did, cid); 34 + if (imageResponse) { 35 + let imageBlob = await imageResponse.blob(); 36 + 37 + // Return the image with appropriate headers 38 + return new Response(imageBlob, { 39 + headers: { 40 + "Content-Type": imageBlob.type || "image/jpeg", 41 + "Cache-Control": "public, max-age=3600", 42 + }, 43 + }); 44 + } 45 + } catch (e) { 46 + // Fall through to screenshot if cover image fetch fails 47 + console.error("Failed to fetch cover image:", e); 48 + } 49 + } 50 + } 51 + 52 + // Fall back to screenshot 10 53 return getMicroLinkOgImage( 11 54 `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 12 55 );
+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,
+12 -4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 1 "use server"; 2 2 3 - import { createOauthClient } from "src/atproto-oauth"; 3 + import { 4 + restoreOAuthSession, 5 + OAuthSessionError, 6 + } from "src/atproto-oauth"; 4 7 import { getIdentityData } from "actions/getIdentityData"; 5 8 import { AtpBaseClient, AtUri } from "@atproto/api"; 6 9 import { PubLeafletPollVote } from "lexicons/api"; ··· 12 15 pollUri: string, 13 16 pollCid: string, 14 17 selectedOption: string, 15 - ): Promise<{ success: boolean; error?: string }> { 18 + ): Promise< 19 + { success: true } | { success: false; error: string | OAuthSessionError } 20 + > { 16 21 try { 17 22 const identity = await getIdentityData(); 18 23 ··· 20 25 return { success: false, error: "Not authenticated" }; 21 26 } 22 27 23 - const oauthClient = await createOauthClient(); 24 - const session = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + const session = sessionResult.value; 25 33 let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 34 27 35 const voteRecord: PubLeafletPollVote.Record = {
+2 -3
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 1 "use client"; 2 2 3 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 - import { PublicationSettingsButton } from "./PublicationSettings"; 4 + import { PublicationSettingsButton } from "./settings/PublicationSettings"; 5 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 - import { Menu } from "components/Layout"; 8 - import { MenuItem } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 9 8 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 9 import { usePublicationData } from "./PublicationSWRProvider"; 11 10 import { useSmoker } from "components/Toast";
+20 -34
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 ··· 55 23 searchValue={props.searchValue} 56 24 showPreview={false} 57 25 defaultDisplay="list" 58 - cardBorderHidden={!props.showPageBackground} 59 - leaflets={filteredLeaflets} 26 + leaflets={leaflets_in_publications 27 + .filter((l) => !l.documents) 28 + .filter((l) => !l.archived) 29 + .map((l) => { 30 + return { 31 + archived: l.archived, 32 + added_at: "", 33 + token: { 34 + ...l.permission_tokens!, 35 + leaflets_in_publications: [ 36 + { 37 + ...l, 38 + publications: { 39 + ...publication, 40 + }, 41 + }, 42 + ], 43 + }, 44 + }; 45 + })} 60 46 initialFacts={pub_data.leaflet_data.facts || {}} 61 47 titles={{ 62 48 ...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}`);
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 39 return ( 40 40 <DashboardLayout 41 41 id={publication.uri} 42 - cardBorderHidden={!!record.theme?.showPageBackground} 43 42 defaultTab="Drafts" 44 43 tabs={{ 45 44 Drafts: {
-132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
··· 1 - "use client"; 2 - 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { Popover } from "components/Popover"; 5 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 - import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 - import { useIsMobile } from "src/hooks/isMobile"; 9 - import { useState } from "react"; 10 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 - import { theme } from "tailwind.config"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { DotLoader } from "components/utils/DotLoader"; 14 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 - 16 - export function PublicationSettingsButton(props: { publication: string }) { 17 - let isMobile = useIsMobile(); 18 - let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 19 - let [loading, setLoading] = useState(false); 20 - 21 - return ( 22 - <Popover 23 - asChild 24 - onOpenChange={() => setState("menu")} 25 - side={isMobile ? "top" : "right"} 26 - align={isMobile ? "center" : "start"} 27 - className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 28 - arrowFill={theme.colors["border-light"]} 29 - trigger={ 30 - <ActionButton 31 - id="pub-settings-button" 32 - icon=<SettingsSmall /> 33 - label="Settings" 34 - /> 35 - } 36 - > 37 - {state === "general" ? ( 38 - <EditPubForm 39 - backToMenuAction={() => setState("menu")} 40 - loading={loading} 41 - setLoadingAction={setLoading} 42 - /> 43 - ) : state === "theme" ? ( 44 - <PubThemeSetter 45 - backToMenu={() => setState("menu")} 46 - loading={loading} 47 - setLoading={setLoading} 48 - /> 49 - ) : ( 50 - <PubSettingsMenu 51 - state={state} 52 - setState={setState} 53 - loading={loading} 54 - setLoading={setLoading} 55 - /> 56 - )} 57 - </Popover> 58 - ); 59 - } 60 - 61 - const PubSettingsMenu = (props: { 62 - state: "menu" | "general" | "theme"; 63 - setState: (s: typeof props.state) => void; 64 - loading: boolean; 65 - setLoading: (l: boolean) => void; 66 - }) => { 67 - let menuItemClassName = 68 - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 69 - 70 - return ( 71 - <div className="flex flex-col gap-0.5"> 72 - <PubSettingsHeader 73 - loading={props.loading} 74 - setLoadingAction={props.setLoading} 75 - state={"menu"} 76 - /> 77 - <button 78 - className={menuItemClassName} 79 - type="button" 80 - onClick={() => { 81 - props.setState("general"); 82 - }} 83 - > 84 - Publication Settings 85 - <ArrowRightTiny /> 86 - </button> 87 - <button 88 - className={menuItemClassName} 89 - type="button" 90 - onClick={() => props.setState("theme")} 91 - > 92 - Publication Theme 93 - <ArrowRightTiny /> 94 - </button> 95 - </div> 96 - ); 97 - }; 98 - 99 - export const PubSettingsHeader = (props: { 100 - state: "menu" | "general" | "theme"; 101 - backToMenuAction?: () => void; 102 - loading: boolean; 103 - setLoadingAction: (l: boolean) => void; 104 - }) => { 105 - return ( 106 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 107 - {props.state === "menu" 108 - ? "Settings" 109 - : props.state === "general" 110 - ? "General" 111 - : props.state === "theme" 112 - ? "Publication Theme" 113 - : ""} 114 - {props.state !== "menu" && ( 115 - <div className="flex gap-2"> 116 - <button 117 - type="button" 118 - onClick={() => { 119 - props.backToMenuAction && props.backToMenuAction(); 120 - }} 121 - > 122 - <GoBackSmall className="text-accent-contrast" /> 123 - </button> 124 - 125 - <ButtonPrimary compact type="submit"> 126 - {props.loading ? <DotLoader /> : "Update"} 127 - </ButtonPrimary> 128 - </div> 129 - )} 130 - </div> 131 - ); 132 - };
+2 -1
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 4 4 import { ButtonPrimary } from "components/Buttons"; 5 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 6 import { useSmoker } from "components/Toast"; 7 - import { Menu, MenuItem, Separator } from "components/Layout"; 7 + import { Menu, MenuItem } from "components/Menu"; 8 + import { Separator } from "components/Layout"; 8 9 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 10 import { Checkbox } from "components/Checkbox"; 10 11 import { useEffect, useState } from "react";
+27 -32
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"; 7 7 import { Fragment, useState } from "react"; 8 8 import { useParams } from "next/navigation"; 9 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Layout"; 10 + import { Menu, MenuItem } from "components/Menu"; 11 11 import { deletePost } from "./deletePost"; 12 12 import { ButtonPrimary } from "components/Buttons"; 13 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; ··· 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 + showMentions={pubRecord?.preferences?.showMentions} 144 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 145 + /> 151 146 </div> 152 147 </div> 153 148 </div>
+50 -13
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 2 2 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { AtUri } from "@atproto/syntax"; 7 10 import { supabaseServerClient } from "supabase/serverClient"; 8 11 import { revalidatePath } from "next/cache"; 9 12 10 - export async function deletePost(document_uri: string) { 13 + export async function deletePost( 14 + document_uri: string 15 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 11 16 let identity = await getIdentityData(); 12 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 17 + if (!identity || !identity.atp_did) { 18 + return { 19 + success: false, 20 + error: { 21 + type: "oauth_session_expired", 22 + message: "Not authenticated", 23 + did: "", 24 + }, 25 + }; 26 + } 13 27 14 - const oauthClient = await createOauthClient(); 15 - let credentialSession = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + let credentialSession = sessionResult.value; 16 33 let agent = new AtpBaseClient( 17 34 credentialSession.fetchHandler.bind(credentialSession), 18 35 ); 19 36 let uri = new AtUri(document_uri); 20 - if (uri.host !== identity.atp_did) return; 37 + if (uri.host !== identity.atp_did) { 38 + return { success: true }; 39 + } 21 40 22 41 await Promise.all([ 23 42 agent.pub.leaflet.document.delete({ ··· 31 50 .eq("doc", document_uri), 32 51 ]); 33 52 34 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 53 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 54 + return { success: true }; 35 55 } 36 56 37 - export async function unpublishPost(document_uri: string) { 57 + export async function unpublishPost( 58 + document_uri: string 59 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 38 60 let identity = await getIdentityData(); 39 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 61 + if (!identity || !identity.atp_did) { 62 + return { 63 + success: false, 64 + error: { 65 + type: "oauth_session_expired", 66 + message: "Not authenticated", 67 + did: "", 68 + }, 69 + }; 70 + } 40 71 41 - const oauthClient = await createOauthClient(); 42 - let credentialSession = await oauthClient.restore(identity.atp_did); 72 + const sessionResult = await restoreOAuthSession(identity.atp_did); 73 + if (!sessionResult.ok) { 74 + return { success: false, error: sessionResult.error }; 75 + } 76 + let credentialSession = sessionResult.value; 43 77 let agent = new AtpBaseClient( 44 78 credentialSession.fetchHandler.bind(credentialSession), 45 79 ); 46 80 let uri = new AtUri(document_uri); 47 - if (uri.host !== identity.atp_did) return; 81 + if (uri.host !== identity.atp_did) { 82 + return { success: true }; 83 + } 48 84 49 85 await Promise.all([ 50 86 agent.pub.leaflet.document.delete({ ··· 53 89 }), 54 90 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 55 91 ]); 56 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 92 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 93 + return { success: true }; 57 94 }
+108
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 + import { PubLeafletPublication } from "lexicons/api"; 2 + import { usePublicationData } from "../PublicationSWRProvider"; 3 + import { PubSettingsHeader } from "./PublicationSettings"; 4 + import { useState } from "react"; 5 + import { Toggle } from "components/Toggle"; 6 + import { updatePublication } from "app/lish/createPub/updatePublication"; 7 + import { useToaster } from "components/Toast"; 8 + import { mutate } from "swr"; 9 + 10 + export const PostOptions = (props: { 11 + backToMenu: () => void; 12 + loading: boolean; 13 + setLoading: (l: boolean) => void; 14 + }) => { 15 + let { data } = usePublicationData(); 16 + 17 + let { publication: pubData } = data || {}; 18 + let record = pubData?.record as PubLeafletPublication.Record; 19 + 20 + let [showComments, setShowComments] = useState( 21 + record?.preferences?.showComments === undefined 22 + ? true 23 + : record.preferences.showComments, 24 + ); 25 + let [showMentions, setShowMentions] = useState( 26 + record?.preferences?.showMentions === undefined 27 + ? true 28 + : record.preferences.showMentions, 29 + ); 30 + let [showPrevNext, setShowPrevNext] = useState( 31 + record?.preferences?.showPrevNext === undefined 32 + ? true 33 + : record.preferences.showPrevNext, 34 + ); 35 + 36 + let toast = useToaster(); 37 + return ( 38 + <form 39 + onSubmit={async (e) => { 40 + if (!pubData) return; 41 + e.preventDefault(); 42 + props.setLoading(true); 43 + let data = await updatePublication({ 44 + name: record.name, 45 + uri: pubData.uri, 46 + preferences: { 47 + showInDiscover: 48 + record?.preferences?.showInDiscover === undefined 49 + ? true 50 + : record.preferences.showInDiscover, 51 + showComments: showComments, 52 + showMentions: showMentions, 53 + showPrevNext: showPrevNext, 54 + }, 55 + }); 56 + toast({ type: "success", content: <strong>Posts Updated!</strong> }); 57 + console.log(record.preferences?.showPrevNext); 58 + props.setLoading(false); 59 + mutate("publication-data"); 60 + }} 61 + className="text-primary flex flex-col" 62 + > 63 + <PubSettingsHeader 64 + loading={props.loading} 65 + setLoadingAction={props.setLoading} 66 + backToMenuAction={props.backToMenu} 67 + state={"post-options"} 68 + > 69 + Post Options 70 + </PubSettingsHeader> 71 + <h4 className="mb-1">Layout</h4> 72 + <Toggle 73 + toggle={showPrevNext} 74 + onToggle={() => { 75 + setShowPrevNext(!showPrevNext); 76 + }} 77 + > 78 + <div className="font-bold">Show Prev/Next Buttons</div> 79 + </Toggle> 80 + <hr className="my-2 border-border-light" /> 81 + <h4 className="mb-1">Interactions</h4> 82 + <div className="flex flex-col gap-2"> 83 + <Toggle 84 + toggle={showComments} 85 + onToggle={() => { 86 + setShowComments(!showComments); 87 + }} 88 + > 89 + <div className="font-bold">Show Comments</div> 90 + </Toggle> 91 + 92 + <Toggle 93 + toggle={showMentions} 94 + onToggle={() => { 95 + setShowMentions(!showMentions); 96 + }} 97 + > 98 + <div className="flex flex-col justify-start"> 99 + <div className="font-bold">Show Mentions</div> 100 + <div className="text-tertiary text-sm leading-tight"> 101 + Display a list of posts on Bluesky that mention your post 102 + </div> 103 + </div> 104 + </Toggle> 105 + </div> 106 + </form> 107 + ); 108 + };
+146
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { Popover } from "components/Popover"; 5 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 + import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 + import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 + import { useIsMobile } from "src/hooks/isMobile"; 9 + import { useState } from "react"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { theme } from "tailwind.config"; 12 + import { ButtonPrimary } from "components/Buttons"; 13 + import { DotLoader } from "components/utils/DotLoader"; 14 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 + import { PostOptions } from "./PostOptions"; 16 + 17 + type menuState = "menu" | "general" | "theme" | "post-options"; 18 + 19 + export function PublicationSettingsButton(props: { publication: string }) { 20 + let isMobile = useIsMobile(); 21 + let [state, setState] = useState<menuState>("menu"); 22 + let [loading, setLoading] = useState(false); 23 + 24 + return ( 25 + <Popover 26 + asChild 27 + onOpenChange={() => setState("menu")} 28 + side={isMobile ? "top" : "right"} 29 + align={isMobile ? "center" : "start"} 30 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 31 + arrowFill={theme.colors["border-light"]} 32 + trigger={ 33 + <ActionButton 34 + id="pub-settings-button" 35 + icon=<SettingsSmall /> 36 + label="Settings" 37 + /> 38 + } 39 + > 40 + {state === "general" ? ( 41 + <EditPubForm 42 + backToMenuAction={() => setState("menu")} 43 + loading={loading} 44 + setLoadingAction={setLoading} 45 + /> 46 + ) : state === "theme" ? ( 47 + <PubThemeSetter 48 + backToMenu={() => setState("menu")} 49 + loading={loading} 50 + setLoading={setLoading} 51 + /> 52 + ) : state === "post-options" ? ( 53 + <PostOptions 54 + backToMenu={() => setState("menu")} 55 + loading={loading} 56 + setLoading={setLoading} 57 + /> 58 + ) : ( 59 + <PubSettingsMenu 60 + state={state} 61 + setState={setState} 62 + loading={loading} 63 + setLoading={setLoading} 64 + /> 65 + )} 66 + </Popover> 67 + ); 68 + } 69 + 70 + const PubSettingsMenu = (props: { 71 + state: menuState; 72 + setState: (s: menuState) => void; 73 + loading: boolean; 74 + setLoading: (l: boolean) => void; 75 + }) => { 76 + let menuItemClassName = 77 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 78 + 79 + return ( 80 + <div className="flex flex-col gap-0.5"> 81 + <PubSettingsHeader 82 + loading={props.loading} 83 + setLoadingAction={props.setLoading} 84 + state={"menu"} 85 + > 86 + Settings 87 + </PubSettingsHeader> 88 + <button 89 + className={menuItemClassName} 90 + type="button" 91 + onClick={() => { 92 + props.setState("general"); 93 + }} 94 + > 95 + General Settings 96 + <ArrowRightTiny /> 97 + </button> 98 + <button 99 + className={menuItemClassName} 100 + type="button" 101 + onClick={() => props.setState("theme")} 102 + > 103 + Theme and Layout 104 + <ArrowRightTiny /> 105 + </button> 106 + <button 107 + className={menuItemClassName} 108 + type="button" 109 + onClick={() => props.setState("post-options")} 110 + > 111 + Post Options 112 + <ArrowRightTiny /> 113 + </button> 114 + </div> 115 + ); 116 + }; 117 + 118 + export const PubSettingsHeader = (props: { 119 + state: menuState; 120 + backToMenuAction?: () => void; 121 + loading: boolean; 122 + setLoadingAction: (l: boolean) => void; 123 + children: React.ReactNode; 124 + }) => { 125 + return ( 126 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 127 + {props.children} 128 + {props.state !== "menu" && ( 129 + <div className="flex gap-2"> 130 + <button 131 + type="button" 132 + onClick={() => { 133 + props.backToMenuAction && props.backToMenuAction(); 134 + }} 135 + > 136 + <GoBackSmall className="text-accent-contrast" /> 137 + </button> 138 + 139 + <ButtonPrimary compact type="submit"> 140 + {props.loading ? <DotLoader /> : "Update"} 141 + </ButtonPrimary> 142 + </div> 143 + )} 144 + </div> 145 + ); 146 + };
+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,
+16 -26
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"; 20 + import { PublicationAuthor } from "./PublicationAuthor"; 19 21 20 22 export default async function Publication(props: { 21 23 params: Promise<{ publication: string; did: string }>; ··· 90 92 {record?.description}{" "} 91 93 </p> 92 94 {profile && ( 93 - <p className="italic text-tertiary sm:text-base text-sm"> 94 - <strong className="">by {profile.displayName}</strong>{" "} 95 - <a 96 - className="text-tertiary" 97 - href={`https://bsky.app/profile/${profile.handle}`} 98 - > 99 - @{profile.handle} 100 - </a> 101 - </p> 95 + <PublicationAuthor 96 + did={profile.did} 97 + displayName={profile.displayName} 98 + handle={profile.handle} 99 + /> 102 100 )} 103 101 <div className="sm:pt-4 pt-4"> 104 102 <SubscribeWithBluesky ··· 134 132 record?.preferences?.showComments === false 135 133 ? 0 136 134 : doc.documents.comments_on_documents[0].count || 0; 135 + let tags = (doc_record?.tags as string[] | undefined) || []; 137 136 138 137 return ( 139 138 <React.Fragment key={doc.documents?.uri}> ··· 162 161 )}{" "} 163 162 </p> 164 163 {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 - )} 164 + <InteractionPreview 165 + quotesCount={quotes} 166 + commentsCount={comments} 167 + tags={tags} 168 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 + showComments={record?.preferences?.showComments} 170 + showMentions={record?.preferences?.showMentions} 171 + /> 182 172 </div> 183 173 </div> 184 174 <hr className="last:hidden border-border-light" />
+22 -6
app/lish/addFeed.tsx
··· 2 2 3 3 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 const leafletFeedURI = 7 10 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 8 11 9 - export async function addFeed() { 10 - const oauthClient = await createOauthClient(); 12 + export async function addFeed(): Promise< 13 + { success: true } | { success: false; error: OAuthSessionError } 14 + > { 11 15 let identity = await getIdentityData(); 12 16 if (!identity || !identity.atp_did) { 13 - throw new Error("Invalid identity data"); 17 + return { 18 + success: false, 19 + error: { 20 + type: "oauth_session_expired", 21 + message: "Not authenticated", 22 + did: "", 23 + }, 24 + }; 14 25 } 15 26 16 - let credentialSession = await oauthClient.restore(identity.atp_did); 27 + const sessionResult = await restoreOAuthSession(identity.atp_did); 28 + if (!sessionResult.ok) { 29 + return { success: false, error: sessionResult.error }; 30 + } 31 + let credentialSession = sessionResult.value; 17 32 let bsky = new BskyAgent(credentialSession); 18 33 let prefs = await bsky.app.bsky.actor.getPreferences(); 19 34 let savedFeeds = prefs.data.preferences.find( ··· 23 38 let hasFeed = !!savedFeeds.items.find( 24 39 (feed) => feed.value === leafletFeedURI, 25 40 ); 26 - if (hasFeed) return; 41 + if (hasFeed) return { success: true }; 27 42 28 43 await bsky.addSavedFeeds([ 29 44 { ··· 32 47 type: "feed", 33 48 }, 34 49 ]); 50 + return { success: true }; 35 51 }
+42 -13
app/lish/createPub/CreatePubForm.tsx
··· 13 13 import { string } from "zod"; 14 14 import { DotLoader } from "components/utils/DotLoader"; 15 15 import { Checkbox } from "components/Checkbox"; 16 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 16 17 17 18 type DomainState = 18 19 | { status: "empty" } ··· 32 33 let [domainState, setDomainState] = useState<DomainState>({ 33 34 status: "empty", 34 35 }); 36 + let [oauthError, setOauthError] = useState< 37 + import("src/atproto-oauth").OAuthSessionError | null 38 + >(null); 35 39 let fileInputRef = useRef<HTMLInputElement>(null); 36 40 37 41 let router = useRouter(); ··· 43 47 e.preventDefault(); 44 48 if (!subdomainValidator.safeParse(domainValue).success) return; 45 49 setFormState("loading"); 46 - let data = await createPublication({ 50 + setOauthError(null); 51 + let result = await createPublication({ 47 52 name: nameValue, 48 53 description: descriptionValue, 49 54 iconFile: logoFile, 50 55 subdomain: domainValue, 51 - preferences: { showInDiscover, showComments: true }, 56 + preferences: { 57 + showInDiscover, 58 + showComments: true, 59 + showMentions: true, 60 + showPrevNext: false, 61 + }, 52 62 }); 63 + 64 + if (!result.success) { 65 + setFormState("normal"); 66 + if (result.error && isOAuthSessionError(result.error)) { 67 + setOauthError(result.error); 68 + } 69 + return; 70 + } 71 + 53 72 // Show a spinner while this is happening! Maybe a progress bar? 54 73 setTimeout(() => { 55 74 setFormState("normal"); 56 - if (data?.publication) 57 - router.push(`${getBasePublicationURL(data.publication)}/dashboard`); 75 + if (result.publication) 76 + router.push( 77 + `${getBasePublicationURL(result.publication)}/dashboard`, 78 + ); 58 79 }, 500); 59 80 }} 60 81 > ··· 139 160 </Checkbox> 140 161 <hr className="border-border-light" /> 141 162 142 - <div className="flex w-full justify-end"> 143 - <ButtonPrimary 144 - type="submit" 145 - disabled={ 146 - !nameValue || !domainValue || domainState.status !== "valid" 147 - } 148 - > 149 - {formState === "loading" ? <DotLoader /> : "Create Publication!"} 150 - </ButtonPrimary> 163 + <div className="flex flex-col gap-2"> 164 + <div className="flex w-full justify-end"> 165 + <ButtonPrimary 166 + type="submit" 167 + disabled={ 168 + !nameValue || !domainValue || domainState.status !== "valid" 169 + } 170 + > 171 + {formState === "loading" ? <DotLoader /> : "Create Publication!"} 172 + </ButtonPrimary> 173 + </div> 174 + {oauthError && ( 175 + <OAuthErrorMessage 176 + error={oauthError} 177 + className="text-right text-sm text-accent-1" 178 + /> 179 + )} 151 180 </div> 152 181 </form> 153 182 );
+23 -16
app/lish/createPub/UpdatePubForm.tsx
··· 20 20 import Link from "next/link"; 21 21 import { Checkbox } from "components/Checkbox"; 22 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 - import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; 23 + import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 + import { Toggle } from "components/Toggle"; 24 25 25 26 export const EditPubForm = (props: { 26 27 backToMenuAction: () => void; ··· 43 44 ? true 44 45 : record.preferences.showComments, 45 46 ); 47 + let showMentions = 48 + record?.preferences?.showMentions === undefined 49 + ? true 50 + : record.preferences.showMentions; 51 + let showPrevNext = 52 + record?.preferences?.showPrevNext === undefined 53 + ? true 54 + : record.preferences.showPrevNext; 55 + 46 56 let [descriptionValue, setDescriptionValue] = useState( 47 57 record?.description || "", 48 58 ); ··· 74 84 preferences: { 75 85 showInDiscover: showInDiscover, 76 86 showComments: showComments, 87 + showMentions: showMentions, 88 + showPrevNext: showPrevNext, 77 89 }, 78 90 }); 79 91 toast({ type: "success", content: "Updated!" }); ··· 86 98 setLoadingAction={props.setLoadingAction} 87 99 backToMenuAction={props.backToMenuAction} 88 100 state={"theme"} 89 - /> 101 + > 102 + General Settings 103 + </PubSettingsHeader> 90 104 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 91 - <div className="flex items-center justify-between gap-2 "> 105 + <div className="flex items-center justify-between gap-2 mt-2 "> 92 106 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 93 107 Logo <span className="font-normal">(optional)</span> 94 108 </p> ··· 158 172 <CustomDomainForm /> 159 173 <hr className="border-border-light" /> 160 174 161 - <Checkbox 162 - checked={showInDiscover} 163 - onChange={(e) => setShowInDiscover(e.target.checked)} 175 + <Toggle 176 + toggle={showInDiscover} 177 + onToggle={() => setShowInDiscover(!showInDiscover)} 164 178 > 165 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 179 + <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 166 180 <p className="font-bold"> 167 181 Show In{" "} 168 182 <a href="/discover" target="_blank"> ··· 177 191 page. You can change this at any time! 178 192 </p> 179 193 </div> 180 - </Checkbox> 194 + </Toggle> 181 195 182 - <Checkbox 183 - checked={showComments} 184 - onChange={(e) => setShowComments(e.target.checked)} 185 - > 186 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 187 - <p className="font-bold">Show comments on posts</p> 188 - </div> 189 - </Checkbox> 196 + 190 197 </div> 191 198 </form> 192 199 );
+24 -5
app/lish/createPub/createPublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 3 import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 - import { createOauthClient } from "src/atproto-oauth"; 4 + import { 5 + restoreOAuthSession, 6 + OAuthSessionError, 7 + } from "src/atproto-oauth"; 5 8 import { getIdentityData } from "actions/getIdentityData"; 6 9 import { supabaseServerClient } from "supabase/serverClient"; 7 10 import { Un$Typed } from "@atproto/api"; ··· 18 21 .min(3) 19 22 .max(63) 20 23 .regex(/^[a-z0-9-]+$/); 24 + type CreatePublicationResult = 25 + | { success: true; publication: any } 26 + | { success: false; error?: OAuthSessionError }; 27 + 21 28 export async function createPublication({ 22 29 name, 23 30 description, ··· 30 37 iconFile: File | null; 31 38 subdomain: string; 32 39 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 33 - }) { 40 + }): Promise<CreatePublicationResult> { 34 41 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 35 42 if (!isSubdomainValid.success) { 36 43 return { success: false }; 37 44 } 38 - const oauthClient = await createOauthClient(); 39 45 let identity = await getIdentityData(); 40 - if (!identity || !identity.atp_did) return; 46 + if (!identity || !identity.atp_did) { 47 + return { 48 + success: false, 49 + error: { 50 + type: "oauth_session_expired", 51 + message: "Not authenticated", 52 + did: "", 53 + }, 54 + }; 55 + } 41 56 42 57 let domain = `${subdomain}.leaflet.pub`; 43 58 44 - let credentialSession = await oauthClient.restore(identity.atp_did); 59 + const sessionResult = await restoreOAuthSession(identity.atp_did); 60 + if (!sessionResult.ok) { 61 + return { success: false, error: sessionResult.error }; 62 + } 63 + let credentialSession = sessionResult.value; 45 64 let agent = new AtpBaseClient( 46 65 credentialSession.fetchHandler.bind(credentialSession), 47 66 );
+66 -18
app/lish/createPub/updatePublication.ts
··· 5 5 PubLeafletPublication, 6 6 PubLeafletThemeColor, 7 7 } from "lexicons/api"; 8 - import { createOauthClient } from "src/atproto-oauth"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 9 import { getIdentityData } from "actions/getIdentityData"; 10 10 import { supabaseServerClient } from "supabase/serverClient"; 11 11 import { Json } from "supabase/database.types"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { $Typed } from "@atproto/api"; 14 + 15 + type UpdatePublicationResult = 16 + | { success: true; publication: any } 17 + | { success: false; error?: OAuthSessionError }; 14 18 15 19 export async function updatePublication({ 16 20 uri, ··· 21 25 }: { 22 26 uri: string; 23 27 name: string; 24 - description: string; 25 - iconFile: File | null; 28 + description?: string; 29 + iconFile?: File | null; 26 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 27 - }) { 28 - const oauthClient = await createOauthClient(); 31 + }): Promise<UpdatePublicationResult> { 29 32 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) return; 33 + if (!identity || !identity.atp_did) { 34 + return { 35 + success: false, 36 + error: { 37 + type: "oauth_session_expired", 38 + message: "Not authenticated", 39 + did: "", 40 + }, 41 + }; 42 + } 31 43 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 44 + const sessionResult = await restoreOAuthSession(identity.atp_did); 45 + if (!sessionResult.ok) { 46 + return { success: false, error: sessionResult.error }; 47 + } 48 + let credentialSession = sessionResult.value; 33 49 let agent = new AtpBaseClient( 34 50 credentialSession.fetchHandler.bind(credentialSession), 35 51 ); ··· 38 54 .select("*") 39 55 .eq("uri", uri) 40 56 .single(); 41 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 57 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 58 + return { success: false }; 59 + } 42 60 let aturi = new AtUri(existingPub.uri); 43 61 44 62 let record: PubLeafletPublication.Record = { ··· 94 112 }: { 95 113 uri: string; 96 114 base_path: string; 97 - }) { 98 - const oauthClient = await createOauthClient(); 115 + }): Promise<UpdatePublicationResult> { 99 116 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 117 + if (!identity || !identity.atp_did) { 118 + return { 119 + success: false, 120 + error: { 121 + type: "oauth_session_expired", 122 + message: "Not authenticated", 123 + did: "", 124 + }, 125 + }; 126 + } 101 127 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 128 + const sessionResult = await restoreOAuthSession(identity.atp_did); 129 + if (!sessionResult.ok) { 130 + return { success: false, error: sessionResult.error }; 131 + } 132 + let credentialSession = sessionResult.value; 103 133 let agent = new AtpBaseClient( 104 134 credentialSession.fetchHandler.bind(credentialSession), 105 135 ); ··· 108 138 .select("*") 109 139 .eq("uri", uri) 110 140 .single(); 111 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 141 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 142 + return { success: false }; 143 + } 112 144 let aturi = new AtUri(existingPub.uri); 113 145 114 146 let record: PubLeafletPublication.Record = { ··· 149 181 backgroundImage?: File | null; 150 182 backgroundRepeat?: number | null; 151 183 backgroundColor: Color; 184 + pageWidth?: number; 152 185 primary: Color; 153 186 pageBackground: Color; 154 187 showPageBackground: boolean; 155 188 accentBackground: Color; 156 189 accentText: Color; 157 190 }; 158 - }) { 159 - const oauthClient = await createOauthClient(); 191 + }): Promise<UpdatePublicationResult> { 160 192 let identity = await getIdentityData(); 161 - if (!identity || !identity.atp_did) return; 193 + if (!identity || !identity.atp_did) { 194 + return { 195 + success: false, 196 + error: { 197 + type: "oauth_session_expired", 198 + message: "Not authenticated", 199 + did: "", 200 + }, 201 + }; 202 + } 162 203 163 - let credentialSession = await oauthClient.restore(identity.atp_did); 204 + const sessionResult = await restoreOAuthSession(identity.atp_did); 205 + if (!sessionResult.ok) { 206 + return { success: false, error: sessionResult.error }; 207 + } 208 + let credentialSession = sessionResult.value; 164 209 let agent = new AtpBaseClient( 165 210 credentialSession.fetchHandler.bind(credentialSession), 166 211 ); ··· 169 214 .select("*") 170 215 .eq("uri", uri) 171 216 .single(); 172 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 217 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 218 + return { success: false }; 219 + } 173 220 let aturi = new AtUri(existingPub.uri); 174 221 175 222 let oldRecord = existingPub.record as PubLeafletPublication.Record; ··· 197 244 ...theme.backgroundColor, 198 245 } 199 246 : undefined, 247 + pageWidth: theme.pageWidth, 200 248 primary: { 201 249 ...theme.primary, 202 250 },
+40 -9
app/lish/subscribeToPublication.ts
··· 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { supabaseServerClient } from "supabase/serverClient"; 9 12 import { revalidatePath } from "next/cache"; ··· 21 24 let leafletFeedURI = 22 25 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 23 26 let idResolver = new IdResolver(); 27 + 28 + type SubscribeResult = 29 + | { success: true; hasFeed: boolean } 30 + | { success: false; error: OAuthSessionError }; 31 + 24 32 export async function subscribeToPublication( 25 33 publication: string, 26 34 redirectRoute?: string, 27 - ) { 28 - const oauthClient = await createOauthClient(); 35 + ): Promise<SubscribeResult | never> { 29 36 let identity = await getIdentityData(); 30 37 if (!identity || !identity.atp_did) { 31 38 return redirect( ··· 33 40 ); 34 41 } 35 42 36 - let credentialSession = await oauthClient.restore(identity.atp_did); 43 + const sessionResult = await restoreOAuthSession(identity.atp_did); 44 + if (!sessionResult.ok) { 45 + return { success: false, error: sessionResult.error }; 46 + } 47 + let credentialSession = sessionResult.value; 37 48 let agent = new AtpBaseClient( 38 49 credentialSession.fetchHandler.bind(credentialSession), 39 50 ); ··· 90 101 ) as AppBskyActorDefs.SavedFeedsPrefV2; 91 102 revalidatePath("/lish/[did]/[publication]", "layout"); 92 103 return { 104 + success: true, 93 105 hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 94 106 }; 95 107 } 96 108 97 - export async function unsubscribeToPublication(publication: string) { 98 - const oauthClient = await createOauthClient(); 109 + type UnsubscribeResult = 110 + | { success: true } 111 + | { success: false; error: OAuthSessionError }; 112 + 113 + export async function unsubscribeToPublication( 114 + publication: string 115 + ): Promise<UnsubscribeResult> { 99 116 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 117 + if (!identity || !identity.atp_did) { 118 + return { 119 + success: false, 120 + error: { 121 + type: "oauth_session_expired", 122 + message: "Not authenticated", 123 + did: "", 124 + }, 125 + }; 126 + } 101 127 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 128 + const sessionResult = await restoreOAuthSession(identity.atp_did); 129 + if (!sessionResult.ok) { 130 + return { success: false, error: sessionResult.error }; 131 + } 132 + let credentialSession = sessionResult.value; 103 133 let agent = new AtpBaseClient( 104 134 credentialSession.fetchHandler.bind(credentialSession), 105 135 ); ··· 109 139 .eq("identity", identity.atp_did) 110 140 .eq("publication", publication) 111 141 .single(); 112 - if (!existingSubscription) return; 142 + if (!existingSubscription) return { success: true }; 113 143 await agent.pub.leaflet.graph.subscription.delete({ 114 144 repo: credentialSession.did!, 115 145 rkey: new AtUri(existingSubscription.uri).rkey, ··· 120 150 .eq("identity", identity.atp_did) 121 151 .eq("publication", publication); 122 152 revalidatePath("/lish/[did]/[publication]", "layout"); 153 + return { success: true }; 123 154 }
+99
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", { 36 + status: 404, 37 + }); 38 + } 39 + 40 + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 41 + return NextResponse.redirect(basePath, 307); 42 + } else if (uri.collection === "pub.leaflet.document") { 43 + // Document link - need to find the publication it belongs to 44 + const { data: docInPub } = await supabaseServerClient 45 + .from("documents_in_publications") 46 + .select("publication, publications!inner(record)") 47 + .eq("document", atUriString) 48 + .single(); 49 + 50 + if (docInPub?.publication && docInPub.publications) { 51 + // Document is in a publication - redirect to domain/rkey 52 + const record = docInPub.publications 53 + .record as PubLeafletPublication.Record; 54 + const basePath = record.base_path; 55 + 56 + if (!basePath) { 57 + return new NextResponse("Publication has no base_path", { 58 + status: 404, 59 + }); 60 + } 61 + 62 + // Ensure basePath ends without trailing slash 63 + const cleanBasePath = basePath.endsWith("/") 64 + ? basePath.slice(0, -1) 65 + : basePath; 66 + 67 + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 68 + return NextResponse.redirect( 69 + `https://${cleanBasePath}/${uri.rkey}`, 70 + 307, 71 + ); 72 + } 73 + 74 + // If not in a publication, check if it's a standalone document 75 + const { data: doc } = await supabaseServerClient 76 + .from("documents") 77 + .select("uri") 78 + .eq("uri", atUriString) 79 + .single(); 80 + 81 + if (doc) { 82 + // Standalone document - redirect to /p/did/rkey (temporary redirect) 83 + return NextResponse.redirect( 84 + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 85 + 307, 86 + ); 87 + } 88 + 89 + // Document not found 90 + return new NextResponse("Document not found", { status: 404 }); 91 + } 92 + 93 + // Unsupported collection type 94 + return new NextResponse("Unsupported URI type", { status: 400 }); 95 + } catch (error) { 96 + console.error("Error resolving AT URI:", error); 97 + return new NextResponse("Invalid URI", { status: 400 }); 98 + } 99 + }
+59 -4
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { PubLeafletDocument } from "lexicons/api"; 6 + import { jsonToLex } from "@atproto/lexicon"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 2 9 3 - export const runtime = "edge"; 4 10 export const revalidate = 60; 5 11 6 12 export default async function OpenGraphImage(props: { 7 13 params: Promise<{ rkey: string; didOrHandle: string }>; 8 14 }) { 9 15 let params = await props.params; 10 - return getMicroLinkOgImage( 11 - `/p/${params.didOrHandle}/${params.rkey}/`, 12 - ); 16 + let didOrHandle = decodeURIComponent(params.didOrHandle); 17 + 18 + // Resolve handle to DID if needed 19 + let did = didOrHandle; 20 + if (!didOrHandle.startsWith("did:")) { 21 + try { 22 + let resolved = await idResolver.handle.resolve(didOrHandle); 23 + if (resolved) did = resolved; 24 + } catch (e) { 25 + // Fall back to screenshot if handle resolution fails 26 + } 27 + } 28 + 29 + if (did) { 30 + // Try to get the document's cover image 31 + let { data: document } = await supabaseServerClient 32 + .from("documents") 33 + .select("data") 34 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 35 + .single(); 36 + 37 + if (document) { 38 + let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 39 + if (docRecord.coverImage) { 40 + try { 41 + // Get CID from the blob ref (handle both serialized and hydrated forms) 42 + let cid = 43 + (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || 44 + docRecord.coverImage.ref.toString(); 45 + 46 + let imageResponse = await fetchAtprotoBlob(did, cid); 47 + if (imageResponse) { 48 + let imageBlob = await imageResponse.blob(); 49 + 50 + // Return the image with appropriate headers 51 + return new Response(imageBlob, { 52 + headers: { 53 + "Content-Type": imageBlob.type || "image/jpeg", 54 + "Cache-Control": "public, max-age=3600", 55 + }, 56 + }); 57 + } 58 + } catch (e) { 59 + // Fall through to screenshot if cover image fetch fails 60 + console.error("Failed to fetch cover image:", e); 61 + } 62 + } 63 + } 64 + } 65 + 66 + // Fall back to screenshot 67 + return getMicroLinkOgImage(`/p/${params.didOrHandle}/${params.rkey}/`); 13 68 }
+9 -7
app/p/[didOrHandle]/[rkey]/page.tsx
··· 5 5 import { Metadata } from "next"; 6 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 7 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 8 9 9 10 export async function generateMetadata(props: { 10 11 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 34 35 let docRecord = document.data as PubLeafletDocument.Record; 35 36 36 37 // For documents in publications, include publication name 37 - let publicationName = document.documents_in_publications[0]?.publications?.name; 38 + let publicationName = 39 + document.documents_in_publications[0]?.publications?.name; 38 40 39 41 return { 40 42 icons: { ··· 63 65 let resolved = await idResolver.handle.resolve(didOrHandle); 64 66 if (!resolved) { 65 67 return ( 66 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 67 - <p>Sorry, can&apos;t resolve handle.</p> 68 + <NotFoundLayout> 69 + <p className="font-bold">Sorry, we can't find this handle!</p> 68 70 <p> 69 71 This may be a glitch on our end. If the issue persists please{" "} 70 72 <a href="mailto:contact@leaflet.pub">send us a note</a>. 71 73 </p> 72 - </div> 74 + </NotFoundLayout> 73 75 ); 74 76 } 75 77 did = resolved; 76 78 } catch (e) { 77 79 return ( 78 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 79 - <p>Sorry, can&apos;t resolve handle.</p> 80 + <NotFoundLayout> 81 + <p className="font-bold">Sorry, we can't find this leaflet!</p> 80 82 <p> 81 83 This may be a glitch on our end. If the issue persists please{" "} 82 84 <a href="mailto:contact@leaflet.pub">send us a note</a>. 83 85 </p> 84 - </div> 86 + </NotFoundLayout> 85 87 ); 86 88 } 87 89 }
+8 -32
appview/index.ts
··· 20 20 } from "@atproto/api"; 21 21 import { AtUri } from "@atproto/syntax"; 22 22 import { writeFile, readFile } from "fs/promises"; 23 - import { createIdentity } from "actions/createIdentity"; 24 - import { drizzle } from "drizzle-orm/node-postgres"; 25 23 import { inngest } from "app/api/inngest/client"; 26 - import { Client } from "pg"; 27 24 28 25 const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 29 26 ··· 135 132 if (evt.event === "create" || evt.event === "update") { 136 133 let record = PubLeafletPublication.validateRecord(evt.record); 137 134 if (!record.success) return; 138 - let { error } = await supabase.from("publications").upsert({ 135 + await supabase 136 + .from("identities") 137 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 138 + await supabase.from("publications").upsert({ 139 139 uri: evt.uri.toString(), 140 140 identity_did: evt.did, 141 141 name: record.value.name, 142 142 record: record.value as Json, 143 143 }); 144 - 145 - if (error && error.code === "23503") { 146 - console.log("creating identity"); 147 - let client = new Client({ connectionString: process.env.DB_URL }); 148 - let db = drizzle(client); 149 - await createIdentity(db, { atp_did: evt.did }); 150 - client.end(); 151 - await supabase.from("publications").upsert({ 152 - uri: evt.uri.toString(), 153 - identity_did: evt.did, 154 - name: record.value.name, 155 - record: record.value as Json, 156 - }); 157 - } 158 144 } 159 145 if (evt.event === "delete") { 160 146 await supabase ··· 222 208 if (evt.event === "create" || evt.event === "update") { 223 209 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 224 210 if (!record.success) return; 225 - let { error } = await supabase.from("publication_subscriptions").upsert({ 211 + await supabase 212 + .from("identities") 213 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 214 + await supabase.from("publication_subscriptions").upsert({ 226 215 uri: evt.uri.toString(), 227 216 identity: evt.did, 228 217 publication: record.value.publication, 229 218 record: record.value as Json, 230 219 }); 231 - if (error && error.code === "23503") { 232 - console.log("creating identity"); 233 - let client = new Client({ connectionString: process.env.DB_URL }); 234 - let db = drizzle(client); 235 - await createIdentity(db, { atp_did: evt.did }); 236 - client.end(); 237 - await supabase.from("publication_subscriptions").upsert({ 238 - uri: evt.uri.toString(), 239 - identity: evt.did, 240 - publication: record.value.publication, 241 - record: record.value as Json, 242 - }); 243 - } 244 220 } 245 221 if (evt.event === "delete") { 246 222 await supabase
+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 };
+5 -8
components/ActionBar/Navigation.tsx
··· 24 24 | "pub" 25 25 | "discover" 26 26 | "notifications" 27 - | "looseleafs"; 27 + | "looseleafs" 28 + | "tag" 29 + | "profile"; 28 30 29 31 export const DesktopNavigation = (props: { 30 32 currentPage: navPages; ··· 126 128 }; 127 129 128 130 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 129 - let readerUnreads = false; 130 - 131 131 if (!props.subs) return; 132 132 return ( 133 133 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 134 <ActionButton 135 135 nav 136 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 136 + icon={<ReaderUnreadSmall />} 137 137 label="Reader" 138 - className={` 139 - ${readerUnreads && "text-accent-contrast!"} 140 - ${props.current && "border-accent-contrast!"} 141 - `} 138 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 142 139 /> 143 140 </SpeedyLink> 144 141 );
+3 -4
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 ··· 194 193 195 194 return props.record.icon ? ( 196 195 <div 197 - className={`${iconSizeClassName} ${props.className} relative overflow-hidden`} 196 + className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`} 198 197 > 199 198 <img 200 199 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+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-4 h-4 rounded-full mr-1 mt-[3px] 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={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+4 -1
components/Avatar.tsx
··· 3 3 export const Avatar = (props: { 4 4 src: string | undefined; 5 5 displayName: string | undefined; 6 + className?: string; 6 7 tiny?: boolean; 8 + large?: boolean; 9 + giant?: boolean; 7 10 }) => { 8 11 if (props.src) 9 12 return ( 10 13 <img 11 - className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 14 + className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 12 15 src={props.src} 13 16 alt={ 14 17 props.displayName
+26
components/Blocks/Block.tsx
··· 383 383 ); 384 384 }; 385 385 386 + export const BlockLayout = (props: { 387 + isSelected?: boolean; 388 + children: React.ReactNode; 389 + className?: string; 390 + hasBackground?: "accent" | "page"; 391 + borderOnHover?: boolean; 392 + }) => { 393 + return ( 394 + <div 395 + className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 396 + ${props.isSelected ? "block-border-selected " : "block-border"} 397 + ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 398 + style={{ 399 + backgroundColor: 400 + props.hasBackground === "accent" 401 + ? "var(--accent-light)" 402 + : props.hasBackground === "page" 403 + ? "rgb(var(--bg-page))" 404 + : "transparent", 405 + }} 406 + > 407 + {props.children} 408 + </div> 409 + ); 410 + }; 411 + 386 412 export const ListMarker = ( 387 413 props: Block & { 388 414 previousBlock?: Block | null;
+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";
+59 -31
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 23 23 return ( 24 24 <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 25 25 {imageEmbed.images.map( 26 - (image: { fullsize: string; alt?: string }, i: number) => ( 27 - <img 28 - key={i} 29 - src={image.fullsize} 30 - alt={image.alt || "Post image"} 31 - className={` 32 - overflow-hidden w-full object-cover 33 - ${imageEmbed.images.length === 1 && "h-auto max-h-[800px]"} 34 - ${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"} 35 - ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 26 + ( 27 + image: { 28 + fullsize: string; 29 + alt?: string; 30 + aspectRatio?: { width: number; height: number }; 31 + }, 32 + i: number, 33 + ) => { 34 + const isSingle = imageEmbed.images.length === 1; 35 + const aspectRatio = image.aspectRatio 36 + ? image.aspectRatio.width / image.aspectRatio.height 37 + : undefined; 38 + 39 + return ( 40 + <img 41 + key={i} 42 + src={image.fullsize} 43 + alt={image.alt || "Post image"} 44 + style={ 45 + isSingle && aspectRatio 46 + ? { aspectRatio: String(aspectRatio) } 47 + : undefined 48 + } 49 + className={` 50 + overflow-hidden w-full object-cover 51 + ${isSingle && "max-h-[800px]"} 52 + ${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"} 53 + ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 36 54 ${ 37 55 imageEmbed.images.length === 4 38 56 ? "basis-1/2 aspect-3/2" 39 - : `basis-1/${imageEmbed.images.length} ` 57 + : `basis-1/${imageEmbed.images.length}` 40 58 } 41 - `} 42 - /> 43 - ), 59 + `} 60 + /> 61 + ); 62 + }, 44 63 )} 45 64 </div> 46 65 ); ··· 49 68 let isGif = externalEmbed.external.uri.includes(".gif"); 50 69 if (isGif) { 51 70 return ( 52 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden"> 71 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 53 72 <img 54 73 src={externalEmbed.external.uri} 55 74 alt={externalEmbed.external.title} 56 - className="object-cover" 75 + className="w-full h-full object-cover" 57 76 /> 58 77 </div> 59 78 ); ··· 66 85 > 67 86 {externalEmbed.external.thumb === undefined ? null : ( 68 87 <> 69 - <img 70 - src={externalEmbed.external.thumb} 71 - alt={externalEmbed.external.title} 72 - className="object-cover" 73 - /> 74 - 75 - <hr className="border-border-light " /> 88 + <div className="w-full aspect-[1.91/1] overflow-hidden"> 89 + <img 90 + src={externalEmbed.external.thumb} 91 + alt={externalEmbed.external.title} 92 + className="w-full h-full object-cover" 93 + /> 94 + </div> 95 + <hr className="border-border-light" /> 76 96 </> 77 97 )} 78 98 <div className="p-2 flex flex-col gap-1"> ··· 91 111 ); 92 112 case AppBskyEmbedVideo.isView(props.embed): 93 113 let videoEmbed = props.embed; 114 + const videoAspectRatio = videoEmbed.aspectRatio 115 + ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height 116 + : 16 / 9; 94 117 return ( 95 - <div className="rounded-md overflow-hidden relative"> 118 + <div 119 + className="rounded-md overflow-hidden relative w-full" 120 + style={{ aspectRatio: String(videoAspectRatio) }} 121 + > 96 122 <img 97 123 src={videoEmbed.thumbnail} 98 124 alt={ 99 125 "Thumbnail from embedded video. Go to Bluesky to see the full post." 100 126 } 101 - className={`overflow-hidden w-full object-cover`} 127 + className="absolute inset-0 w-full h-full object-cover" 102 128 /> 103 - <div className="overlay absolute top-0 right-0 left-0 bottom-0 bg-primary opacity-65" /> 129 + <div className="overlay absolute inset-0 bg-primary opacity-65" /> 104 130 <div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md"> 105 131 <SeePostOnBluesky postUrl={props.postUrl} /> 106 132 </div> ··· 122 148 } 123 149 return ( 124 150 <div 125 - className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 151 + className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 126 152 > 127 - <div className="bskyAuthor w-full flex items-center gap-1"> 153 + <div className="bskyAuthor w-full flex items-center "> 128 154 {record.author.avatar && ( 129 155 <img 130 156 src={record.author?.avatar} 131 157 alt={`${record.author?.displayName}'s avatar`} 132 - className="shink-0 w-6 h-6 rounded-full border border-border-light" 158 + className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 133 159 /> 134 160 )} 135 - <div className=" font-bold text-secondary"> 161 + <div className=" font-bold text-secondary mr-1"> 136 162 {record.author?.displayName} 137 163 </div> 138 164 <a ··· 145 171 </div> 146 172 147 173 <div className="flex flex-col gap-2 "> 148 - {text && <pre className="whitespace-pre-wrap">{text}</pre>} 174 + {text && ( 175 + <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 + )} 149 177 {record.embeds !== undefined 150 178 ? record.embeds.map((embed, index) => ( 151 179 <BlueskyEmbed embed={embed} key={index} />
+9 -12
components/Blocks/BlueskyPostBlock/index.tsx
··· 2 2 import { useEffect, useState } from "react"; 3 3 import { useEntity } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { BlockProps } from "../Block"; 5 + import { BlockProps, BlockLayout } from "../Block"; 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; ··· 56 56 AppBskyFeedDefs.isBlockedAuthor(post) || 57 57 AppBskyFeedDefs.isNotFoundPost(post): 58 58 return ( 59 - <div 60 - className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`} 61 - > 59 + <BlockLayout isSelected={!!isSelected} className="w-full"> 62 60 <PostNotAvailable /> 63 - </div> 61 + </BlockLayout> 64 62 ); 65 63 66 64 case AppBskyFeedDefs.isThreadViewPost(post): ··· 81 79 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 82 80 83 81 return ( 84 - <div 85 - className={` 86 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 87 - ${isSelected ? "block-border-selected " : "block-border"} 88 - `} 82 + <BlockLayout 83 + isSelected={!!isSelected} 84 + hasBackground="page" 85 + className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 89 86 > 90 87 {post.post.author && record && ( 91 88 <> ··· 130 127 <div className="w-full flex gap-2 items-center justify-between"> 131 128 {timestamp && <PostDate timestamp={timestamp} />} 132 129 <div className="flex gap-2 items-center"> 133 - {post.post.replyCount && post.post.replyCount > 0 && ( 130 + {post.post.replyCount != null && post.post.replyCount > 0 && ( 134 131 <> 135 132 <a 136 133 className="flex items-center gap-1 hover:no-underline" ··· 149 146 </a> 150 147 </div> 151 148 </div> 152 - </div> 149 + </BlockLayout> 153 150 ); 154 151 } 155 152 };
+103 -103
components/Blocks/ButtonBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 ··· 106 106 }; 107 107 108 108 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full"> 109 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 110 <ButtonPrimary className="mx-auto"> 111 111 {text !== "" ? text : "Button"} 112 112 </ButtonPrimary> 113 - 114 - <form 115 - className={` 116 - buttonBlockSettingsBorder 117 - w-full bg-bg-page 118 - text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0 119 - flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 120 - ${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-px"} 121 - `} 122 - onSubmit={(e) => { 123 - e.preventDefault(); 124 - let rect = document 125 - .getElementById("button-block-settings") 126 - ?.getBoundingClientRect(); 127 - if (!textValue) { 128 - smoker({ 129 - error: true, 130 - text: "missing button text!", 131 - position: { 132 - y: rect ? rect.top : 0, 133 - x: rect ? rect.left + 12 : 0, 134 - }, 135 - }); 136 - return; 137 - } 138 - if (!urlValue) { 139 - smoker({ 140 - error: true, 141 - text: "missing url!", 142 - position: { 143 - y: rect ? rect.top : 0, 144 - x: rect ? rect.left + 12 : 0, 145 - }, 146 - }); 147 - return; 148 - } 149 - if (!isUrl(urlValue)) { 150 - smoker({ 151 - error: true, 152 - text: "invalid url!", 153 - position: { 154 - y: rect ? rect.top : 0, 155 - x: rect ? rect.left + 12 : 0, 156 - }, 157 - }); 158 - return; 159 - } 160 - submit(); 161 - }} 113 + <BlockLayout 114 + isSelected={!!isSelected} 115 + borderOnHover 116 + hasBackground="accent" 117 + className="buttonBlockSettings text-tertiar hover:cursor-pointer border-dashed! p-0!" 162 118 > 163 - <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 164 - <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 165 - <BlockButtonSmall 166 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 167 - /> 168 - <Separator /> 169 - <Input 170 - type="text" 171 - autoFocus 172 - className="w-full grow border-none outline-hidden bg-transparent" 173 - placeholder="button text" 174 - value={textValue} 175 - disabled={isLocked} 176 - onChange={(e) => setTextValue(e.target.value)} 177 - onKeyDown={(e) => { 178 - if ( 179 - e.key === "Backspace" && 180 - !e.currentTarget.value && 181 - urlValue !== "" 182 - ) 183 - e.preventDefault(); 184 - }} 185 - /> 186 - </div> 187 - <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 188 - <LinkSmall 189 - className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 190 - /> 191 - <Separator /> 192 - <Input 193 - type="text" 194 - id="button-block-url-input" 195 - className="w-full grow border-none outline-hidden bg-transparent" 196 - placeholder="www.example.com" 197 - value={urlValue} 198 - disabled={isLocked} 199 - onChange={(e) => setUrlValue(e.target.value)} 200 - onKeyDown={(e) => { 201 - if (e.key === "Backspace" && !e.currentTarget.value) 202 - e.preventDefault(); 203 - }} 204 - /> 119 + <form 120 + className={`w-full`} 121 + onSubmit={(e) => { 122 + e.preventDefault(); 123 + let rect = document 124 + .getElementById("button-block-settings") 125 + ?.getBoundingClientRect(); 126 + if (!textValue) { 127 + smoker({ 128 + error: true, 129 + text: "missing button text!", 130 + position: { 131 + y: rect ? rect.top : 0, 132 + x: rect ? rect.left + 12 : 0, 133 + }, 134 + }); 135 + return; 136 + } 137 + if (!urlValue) { 138 + smoker({ 139 + error: true, 140 + text: "missing url!", 141 + position: { 142 + y: rect ? rect.top : 0, 143 + x: rect ? rect.left + 12 : 0, 144 + }, 145 + }); 146 + return; 147 + } 148 + if (!isUrl(urlValue)) { 149 + smoker({ 150 + error: true, 151 + text: "invalid url!", 152 + position: { 153 + y: rect ? rect.top : 0, 154 + x: rect ? rect.left + 12 : 0, 155 + }, 156 + }); 157 + return; 158 + } 159 + submit(); 160 + }} 161 + > 162 + <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 163 + <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 164 + <BlockButtonSmall 165 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 166 + /> 167 + <Separator /> 168 + <Input 169 + type="text" 170 + autoFocus 171 + className="w-full grow border-none outline-hidden bg-transparent" 172 + placeholder="button text" 173 + value={textValue} 174 + disabled={isLocked} 175 + onChange={(e) => setTextValue(e.target.value)} 176 + onKeyDown={(e) => { 177 + if ( 178 + e.key === "Backspace" && 179 + !e.currentTarget.value && 180 + urlValue !== "" 181 + ) 182 + e.preventDefault(); 183 + }} 184 + /> 185 + </div> 186 + <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 187 + <LinkSmall 188 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 189 + /> 190 + <Separator /> 191 + <Input 192 + type="text" 193 + id="button-block-url-input" 194 + className="w-full grow border-none outline-hidden bg-transparent" 195 + placeholder="www.example.com" 196 + value={urlValue} 197 + disabled={isLocked} 198 + onChange={(e) => setUrlValue(e.target.value)} 199 + onKeyDown={(e) => { 200 + if (e.key === "Backspace" && !e.currentTarget.value) 201 + e.preventDefault(); 202 + }} 203 + /> 204 + </div> 205 + <button 206 + id="button-block-settings" 207 + type="submit" 208 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 + > 210 + <div className="sm:hidden block">Save</div> 211 + <CheckTiny /> 212 + </button> 205 213 </div> 206 - <button 207 - id="button-block-settings" 208 - type="submit" 209 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 210 - > 211 - <div className="sm:hidden block">Save</div> 212 - <CheckTiny /> 213 - </button> 214 - </div> 215 - </form> 214 + </form> 215 + </BlockLayout> 216 216 </div> 217 217 ); 218 218 };
+17 -6
components/Blocks/CodeBlock.tsx
··· 6 6 } from "shiki"; 7 7 import { useEntity, useReplicache } from "src/replicache"; 8 8 import "katex/dist/katex.min.css"; 9 - import { BlockProps } from "./Block"; 9 + import { BlockLayout, BlockProps } from "./Block"; 10 10 import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 11 import { useUIState } from "src/useUIState"; 12 12 import { BaseTextareaBlock } from "./BaseTextareaBlock"; ··· 119 119 </select> 120 120 </div> 121 121 )} 122 - <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 122 + 123 + <BlockLayout 124 + isSelected={focusedBlock} 125 + hasBackground="accent" 126 + borderOnHover 127 + className="p-0! min-h-[48px]" 128 + > 123 129 {focusedBlock && permissions.write ? ( 124 130 <BaseTextareaBlock 131 + placeholder="write some codeโ€ฆ" 125 132 data-editable-block 126 133 data-entityid={props.entityID} 127 134 id={elementId.block(props.entityID).input} ··· 131 138 spellCheck={false} 132 139 autoCapitalize="none" 133 140 autoCorrect="off" 134 - className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 141 + className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3" 135 142 value={content?.data.value} 136 143 onChange={async (e) => { 137 144 // Update the entity with the new value ··· 146 153 <pre 147 154 onClick={onClick} 148 155 onMouseDown={(e) => e.stopPropagation()} 149 - className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full" 156 + className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full" 150 157 > 151 - {content?.data.value} 158 + {content?.data.value === "" || content?.data.value === undefined ? ( 159 + <div className="text-tertiary italic">write some codeโ€ฆ</div> 160 + ) : ( 161 + content?.data.value 162 + )} 152 163 </pre> 153 164 ) : ( 154 165 <div ··· 159 170 dangerouslySetInnerHTML={{ __html: html || "" }} 160 171 /> 161 172 )} 162 - </div> 173 + </BlockLayout> 163 174 </div> 164 175 ); 165 176 }
+5 -5
components/Blocks/DateTimeBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockProps, BlockLayout } from "./Block"; 3 3 import { ChevronProps, DayPicker } from "react-day-picker"; 4 4 import { Popover } from "components/Popover"; 5 5 import { useEffect, useMemo, useState } from "react"; ··· 121 121 disabled={isLocked || !permissions.write} 122 122 className="w-64 z-10 px-2!" 123 123 trigger={ 124 - <div 125 - className={`flex flex-row gap-2 group/date w-64 z-1 126 - ${isSelected ? "block-border-selected border-transparent!" : "border border-transparent"} 124 + <BlockLayout 125 + isSelected={!!isSelected} 126 + className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent! 127 127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"} 128 128 `} 129 129 > ··· 163 163 </div> 164 164 )} 165 165 </FadeIn> 166 - </div> 166 + </BlockLayout> 167 167 } 168 168 > 169 169 <div className="flex flex-col gap-3 ">
+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 - }
+13 -16
components/Blocks/EmbedBlock.tsx
··· 3 3 import { useCallback, useEffect, useState } from "react"; 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { BlockProps } from "./Block"; 6 + import { BlockProps, BlockLayout } from "./Block"; 7 7 import { v7 } from "uuid"; 8 8 import { useSmoker } from "components/Toast"; 9 9 import { Separator } from "components/Layout"; ··· 84 84 <div 85 85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`} 86 86 > 87 - {/* 88 - the iframe! 89 - can also add 'allow' and 'referrerpolicy' attributes later if needed 90 - */} 91 - <iframe 92 - className={` 93 - flex flex-col relative w-full overflow-hidden group/embedBlock 94 - ${isSelected ? "block-border-selected " : "block-border"} 95 - `} 96 - width="100%" 97 - height={height + (heightHandle.dragDelta?.y || 0)} 98 - src={url?.data.value} 99 - allow="fullscreen" 100 - loading="lazy" 101 - ></iframe> 87 + <BlockLayout 88 + isSelected={!!isSelected} 89 + className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" 90 + > 91 + <iframe 92 + width="100%" 93 + height={height + (heightHandle.dragDelta?.y || 0)} 94 + src={url?.data.value} 95 + allow="fullscreen" 96 + loading="lazy" 97 + ></iframe> 98 + </BlockLayout> 102 99 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast"> 103 100 <a 104 101 href={url?.data.value}
+45 -43
components/Blocks/ExternalLinkBlock.tsx
··· 4 4 import { useEntity, useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import { addLinkBlock } from "src/utils/addLinkBlock"; 7 - import { BlockProps } from "./Block"; 7 + import { BlockProps, BlockLayout } from "./Block"; 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"; ··· 63 64 } 64 65 65 66 return ( 66 - <a 67 - href={url?.data.value} 68 - target="_blank" 69 - className={` 70 - externalLinkBlock flex relative group/linkBlock 71 - h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 72 - hover:border-accent-contrast shadow-sm 73 - ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"} 74 - 75 - `} 67 + <BlockLayout 68 + isSelected={!!isSelected} 69 + hasBackground="page" 70 + borderOnHover 71 + className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!" 76 72 > 77 - <div className="pt-2 pb-2 px-3 grow min-w-0"> 78 - <div className="flex flex-col w-full min-w-0 h-full grow "> 79 - <div 80 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 81 - style={{ 82 - overflow: "hidden", 83 - textOverflow: "ellipsis", 84 - wordBreak: "break-all", 85 - }} 86 - > 87 - {title?.data.value} 88 - </div> 73 + <a 74 + href={url?.data.value} 75 + target="_blank" 76 + className="flex w-full h-full text-primary hover:no-underline no-underline" 77 + > 78 + <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 + <div className="flex flex-col w-full min-w-0 h-full grow "> 80 + <div 81 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 + style={{ 83 + overflow: "hidden", 84 + textOverflow: "ellipsis", 85 + wordBreak: "break-all", 86 + }} 87 + > 88 + {title?.data.value} 89 + </div> 89 90 90 - <div 91 - className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 92 - > 93 - {description?.data.value} 94 - </div> 95 - <div 96 - style={{ wordBreak: "break-word" }} // better than tailwind break-all! 97 - className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 98 - > 99 - {url?.data.value} 91 + <div 92 + className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 + > 94 + {description?.data.value} 95 + </div> 96 + <div 97 + style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 + className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 + > 100 + {url?.data.value} 101 + </div> 100 102 </div> 101 103 </div> 102 - </div> 103 104 104 - <div 105 - className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 106 - style={{ 107 - backgroundImage: `url(${previewImage?.data.src})`, 108 - backgroundPosition: "center", 109 - }} 110 - /> 111 - </a> 105 + <div 106 + className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 + style={{ 108 + backgroundImage: `url(${previewImage?.data.src})`, 109 + backgroundPosition: "center", 110 + }} 111 + /> 112 + </a> 113 + </BlockLayout> 112 114 ); 113 115 }; 114 116
+68 -24
components/Blocks/ImageBlock.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { BlockProps } from "./Block"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import Image from "next/image"; 7 7 import { v7 } from "uuid"; ··· 17 17 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18 18 import { set } from "colorjs.io/fn"; 19 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 + import { useSubscribe } from "src/replicache/useSubscribe"; 22 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 20 23 21 24 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 22 25 let { rep } = useReplicache(); ··· 61 64 factID: v7(), 62 65 permission_set: entity_set.set, 63 66 type: "text", 64 - position: generateKeyBetween( 65 - props.position, 66 - props.nextPosition, 67 - ), 67 + position: generateKeyBetween(props.position, props.nextPosition), 68 68 newEntityID: entity, 69 69 }); 70 70 } ··· 82 82 if (!image) { 83 83 if (!entity_set.permissions.write) return null; 84 84 return ( 85 - <div className="grow w-full"> 85 + <BlockLayout 86 + hasBackground="accent" 87 + isSelected={!!isSelected && !isLocked} 88 + borderOnHover 89 + className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 + > 86 91 <label 87 92 className={` 88 - group/image-block 89 - w-full h-[104px] hover:cursor-pointer p-2 90 - text-tertiary hover:text-accent-contrast hover:font-bold 93 + 94 + w-full h-full hover:cursor-pointer 91 95 flex flex-col items-center justify-center 92 - hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 93 - ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 94 96 ${props.pageType === "canvas" && "bg-bg-page"}`} 95 97 onMouseDown={(e) => e.preventDefault()} 96 98 onDragOver={(e) => { ··· 104 106 const files = e.dataTransfer.files; 105 107 if (files && files.length > 0) { 106 108 const file = files[0]; 107 - if (file.type.startsWith('image/')) { 109 + if (file.type.startsWith("image/")) { 108 110 await handleImageUpload(file); 109 111 } 110 112 } ··· 128 130 }} 129 131 /> 130 132 </label> 131 - </div> 133 + </BlockLayout> 132 134 ); 133 135 } 134 136 135 - let className = isFullBleed 137 + let imageClassName = isFullBleed 136 138 ? "" 137 139 : isSelected 138 140 ? "block-border-selected border-transparent! " ··· 140 142 141 143 let isLocalUpload = localImages.get(image.data.src); 142 144 145 + let blockClassName = ` 146 + relative group/image border-transparent! p-0! w-fit! 147 + ${isFullBleed && "-mx-3 sm:-mx-4"} 148 + ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 + ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 + `; 151 + 143 152 return ( 144 - <div 145 - className={`relative group/image 146 - ${className} 147 - ${isFullBleed && "-mx-3 sm:-mx-4"} 148 - ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 - ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 150 - > 151 - {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 153 + <BlockLayout isSelected={!!isSelected} className={blockClassName}> 152 154 {isLocalUpload || image.data.local ? ( 153 155 <img 154 156 loading="lazy" ··· 166 168 } 167 169 height={image?.data.height} 168 170 width={image?.data.width} 169 - className={className} 171 + className={imageClassName} 170 172 /> 171 173 )} 172 174 {altText !== undefined && !props.preview ? ( 173 175 <ImageAlt entityID={props.value} /> 174 176 ) : null} 175 - </div> 177 + {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 178 + </BlockLayout> 176 179 ); 177 180 } 178 181 ··· 188 191 altEditorOpen: false, 189 192 setAltEditorOpen: (s: boolean) => {}, 190 193 }); 194 + 195 + const CoverImageButton = (props: { entityID: string }) => { 196 + let { rep } = useReplicache(); 197 + let entity_set = useEntitySetContext(); 198 + let { data: pubData } = useLeafletPublicationData(); 199 + let coverImage = useSubscribe(rep, (tx) => 200 + tx.get<string | null>("publication_cover_image"), 201 + ); 202 + let isFocused = useUIState( 203 + (s) => s.focusedEntity?.entityID === props.entityID, 204 + ); 205 + 206 + // Only show if focused, in a publication, has write permissions, and no cover image is set 207 + if ( 208 + !isFocused || 209 + !pubData?.publications || 210 + !entity_set.permissions.write || 211 + coverImage 212 + ) 213 + return null; 214 + 215 + return ( 216 + <div className="absolute top-2 left-2"> 217 + <button 218 + className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 219 + onClick={async (e) => { 220 + e.preventDefault(); 221 + e.stopPropagation(); 222 + await rep?.mutate.updatePublicationDraft({ 223 + cover_image: props.entityID, 224 + }); 225 + }} 226 + > 227 + <span className="w-4 h-4 flex items-center justify-center"> 228 + <ImageCoverImage /> 229 + </span> 230 + Set as Cover 231 + </button> 232 + </div> 233 + ); 234 + }; 191 235 192 236 const ImageAlt = (props: { entityID: string }) => { 193 237 let { rep } = useReplicache();
+81 -95
components/Blocks/MailboxBlock.tsx
··· 1 1 import { ButtonPrimary } from "components/Buttons"; 2 2 import { Popover } from "components/Popover"; 3 - import { Menu, MenuItem, Separator } from "components/Layout"; 3 + import { MenuItem } from "components/Menu"; 4 + import { Separator } from "components/Layout"; 4 5 import { useUIState } from "src/useUIState"; 5 6 import { useState } from "react"; 6 7 import { useSmoker, useToaster } from "components/Toast"; 7 - import { BlockProps } from "./Block"; 8 + import { BlockProps, BlockLayout } from "./Block"; 8 9 import { useEntity, useReplicache } from "src/replicache"; 9 10 import { useEntitySetContext } from "components/EntitySetProvider"; 10 11 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 12 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 13 + import { focusPage } from "src/utils/focusPage"; 13 14 import { v7 } from "uuid"; 14 15 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 16 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 45 46 46 47 return ( 47 48 <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 48 - <div 49 - className={`flex flex-col gap-2 items-center justify-center w-full 50 - ${isSelected ? "block-border-selected " : "block-border"} `} 51 - style={{ 52 - backgroundColor: 53 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 54 - }} 49 + <BlockLayout 50 + isSelected={!!isSelected} 51 + hasBackground={"accent"} 52 + className="flex gap-2 items-center justify-center" 55 53 > 56 - <div className="flex gap-2 p-4"> 57 - <ButtonPrimary 58 - onClick={async () => { 59 - let entity; 60 - if (draft) { 61 - entity = draft.data.value; 62 - } else { 63 - entity = v7(); 64 - await rep?.mutate.createDraft({ 65 - mailboxEntity: props.entityID, 66 - permission_set: entity_set.set, 67 - newEntity: entity, 68 - firstBlockEntity: v7(), 69 - firstBlockFactID: v7(), 70 - }); 71 - } 72 - useUIState.getState().openPage(props.parent, entity); 73 - if (rep) focusPage(entity, rep, "focusFirstBlock"); 74 - return; 75 - }} 76 - > 77 - {draft ? "Edit Draft" : "Write a Post"} 78 - </ButtonPrimary> 79 - <MailboxInfo /> 80 - </div> 81 - </div> 54 + <ButtonPrimary 55 + onClick={async () => { 56 + let entity; 57 + if (draft) { 58 + entity = draft.data.value; 59 + } else { 60 + entity = v7(); 61 + await rep?.mutate.createDraft({ 62 + mailboxEntity: props.entityID, 63 + permission_set: entity_set.set, 64 + newEntity: entity, 65 + firstBlockEntity: v7(), 66 + firstBlockFactID: v7(), 67 + }); 68 + } 69 + useUIState.getState().openPage(props.parent, entity); 70 + if (rep) focusPage(entity, rep, "focusFirstBlock"); 71 + return; 72 + }} 73 + > 74 + {draft ? "Edit Draft" : "Write a Post"} 75 + </ButtonPrimary> 76 + <MailboxInfo /> 77 + </BlockLayout> 82 78 <div className="flex gap-3 items-center justify-between"> 83 79 { 84 80 <> ··· 134 130 let { rep } = useReplicache(); 135 131 return ( 136 132 <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}> 137 - <div 138 - className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 139 - isSelected 140 - ? "border-border outline-border" 141 - : "border-border-light outline-transparent" 142 - }`} 143 - style={{ 144 - backgroundColor: 145 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 146 - }} 133 + <BlockLayout 134 + isSelected={!!isSelected} 135 + hasBackground={"accent"} 136 + className="`h-full flex flex-col gap-2 items-center justify-center" 147 137 > 148 - <div className="flex flex-col w-full gap-2 p-4"> 149 - {!isSubscribed?.confirmed ? ( 150 - <> 151 - <SubscribeForm 152 - entityID={props.entityID} 153 - role={"reader"} 154 - parent={props.parent} 155 - /> 156 - </> 157 - ) : ( 158 - <div className="flex flex-col gap-2 items-center place-self-center"> 159 - <div className=" font-bold text-secondary "> 160 - You&apos;re Subscribed! 161 - </div> 162 - <div className="flex flex-col gap-1 items-center place-self-center"> 163 - {archive ? ( 164 - <ButtonPrimary 165 - onMouseDown={(e) => { 166 - e.preventDefault(); 167 - if (rep) { 168 - useUIState 169 - .getState() 170 - .openPage(props.parent, archive.data.value); 171 - focusPage(archive.data.value, rep); 172 - } 173 - }} 174 - > 175 - See All Posts 176 - </ButtonPrimary> 177 - ) : ( 178 - <div className="text-tertiary"> 179 - Nothing has been posted yet 180 - </div> 181 - )} 182 - <button 183 - className="text-accent-contrast hover:underline text-sm" 184 - onClick={(e) => { 185 - let rect = e.currentTarget.getBoundingClientRect(); 186 - unsubscribe(isSubscribed); 187 - smoke({ 188 - text: "unsubscribed!", 189 - position: { x: rect.left, y: rect.top - 8 }, 190 - }); 138 + {!isSubscribed?.confirmed ? ( 139 + <> 140 + <SubscribeForm 141 + entityID={props.entityID} 142 + role={"reader"} 143 + parent={props.parent} 144 + /> 145 + </> 146 + ) : ( 147 + <div className="flex flex-col gap-2 items-center place-self-center"> 148 + <div className=" font-bold text-secondary "> 149 + You&apos;re Subscribed! 150 + </div> 151 + <div className="flex flex-col gap-1 items-center place-self-center"> 152 + {archive ? ( 153 + <ButtonPrimary 154 + onMouseDown={(e) => { 155 + e.preventDefault(); 156 + if (rep) { 157 + useUIState 158 + .getState() 159 + .openPage(props.parent, archive.data.value); 160 + focusPage(archive.data.value, rep); 161 + } 191 162 }} 192 163 > 193 - unsubscribe 194 - </button> 195 - </div> 164 + See All Posts 165 + </ButtonPrimary> 166 + ) : ( 167 + <div className="text-tertiary">Nothing has been posted yet</div> 168 + )} 169 + <button 170 + className="text-accent-contrast hover:underline text-sm" 171 + onClick={(e) => { 172 + let rect = e.currentTarget.getBoundingClientRect(); 173 + unsubscribe(isSubscribed); 174 + smoke({ 175 + text: "unsubscribed!", 176 + position: { x: rect.left, y: rect.top - 8 }, 177 + }); 178 + }} 179 + > 180 + unsubscribe 181 + </button> 196 182 </div> 197 - )} 198 - </div> 199 - </div> 183 + </div> 184 + )} 185 + </BlockLayout> 200 186 </div> 201 187 ); 202 188 };
+33 -23
components/Blocks/MathBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import "katex/dist/katex.min.css"; 3 - import { BlockProps } from "./Block"; 3 + import { BlockLayout, BlockProps } from "./Block"; 4 4 import Katex from "katex"; 5 5 import { useMemo } from "react"; 6 6 import { useUIState } from "src/useUIState"; ··· 32 32 } 33 33 }, [content?.data.value]); 34 34 return focusedBlock ? ( 35 - <BaseTextareaBlock 36 - id={elementId.block(props.entityID).input} 37 - block={props} 38 - spellCheck={false} 39 - autoCapitalize="none" 40 - autoCorrect="off" 41 - className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 42 - placeholder="write some Tex here..." 43 - value={content?.data.value} 44 - onChange={async (e) => { 45 - // Update the entity with the new value 46 - await rep?.mutate.assertFact({ 47 - attribute: "block/math", 48 - entity: props.entityID, 49 - data: { type: "string", value: e.target.value }, 50 - }); 51 - }} 52 - /> 35 + <BlockLayout 36 + isSelected={focusedBlock} 37 + hasBackground="accent" 38 + className="min-h-[48px]" 39 + > 40 + <BaseTextareaBlock 41 + id={elementId.block(props.entityID).input} 42 + block={props} 43 + spellCheck={false} 44 + autoCapitalize="none" 45 + autoCorrect="off" 46 + className="h-full w-full whitespace-nowrap overflow-auto!" 47 + placeholder="write some Tex here..." 48 + value={content?.data.value} 49 + onChange={async (e) => { 50 + // Update the entity with the new value 51 + await rep?.mutate.assertFact({ 52 + attribute: "block/math", 53 + entity: props.entityID, 54 + data: { type: "string", value: e.target.value }, 55 + }); 56 + }} 57 + /> 58 + </BlockLayout> 53 59 ) : html && content?.data.value ? ( 54 60 <div 55 - className="text-lg min-h-[66px] w-full border border-transparent" 61 + className="text-lg min-h-[48px] w-full border border-transparent" 56 62 dangerouslySetInnerHTML={{ __html: html }} 57 63 /> 58 64 ) : ( 59 - <div className="text-tertiary italic rounded-md p-2 w-full min-h-16"> 60 - write some Tex here... 61 - </div> 65 + <BlockLayout 66 + isSelected={focusedBlock} 67 + hasBackground="accent" 68 + className="min-h-[48px]" 69 + > 70 + <div className="text-tertiary italic w-full ">write some Tex here...</div> 71 + </BlockLayout> 62 72 ); 63 73 }
+27 -23
components/Blocks/PageLinkBlock.tsx
··· 1 1 "use client"; 2 - import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 2 + import { BlockProps, ListMarker, Block, BlockLayout } 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"; ··· 29 29 30 30 return ( 31 31 <CardThemeProvider entityID={page?.data.value}> 32 - <div 33 - className={`w-full cursor-pointer 32 + <BlockLayout 33 + hasBackground="page" 34 + isSelected={!!isSelected} 35 + className={`cursor-pointer 34 36 pageLinkBlockWrapper relative group/pageLinkBlock 35 - bg-bg-page shadow-sm 36 - flex overflow-clip 37 - ${isSelected ? "block-border-selected " : "block-border"} 38 - ${isOpen && "border-tertiary!"} 37 + flex overflow-clip p-0! 38 + ${isOpen && "border-accent-contrast! outline-accent-contrast!"} 39 39 `} 40 - onClick={(e) => { 41 - if (!page) return; 42 - if (e.isDefaultPrevented()) return; 43 - if (e.shiftKey) return; 44 - e.preventDefault(); 45 - e.stopPropagation(); 46 - useUIState.getState().openPage(props.parent, page.data.value); 47 - if (rep) focusPage(page.data.value, rep); 48 - }} 49 40 > 50 - {type === "canvas" && page ? ( 51 - <CanvasLinkBlock entityID={page?.data.value} /> 52 - ) : ( 53 - <DocLinkBlock {...props} /> 54 - )} 55 - </div> 41 + <div 42 + className="w-full h-full" 43 + onClick={(e) => { 44 + if (!page) return; 45 + if (e.isDefaultPrevented()) return; 46 + if (e.shiftKey) return; 47 + e.preventDefault(); 48 + e.stopPropagation(); 49 + useUIState.getState().openPage(props.parent, page.data.value); 50 + if (rep) focusPage(page.data.value, rep); 51 + }} 52 + > 53 + {type === "canvas" && page ? ( 54 + <CanvasLinkBlock entityID={page?.data.value} /> 55 + ) : ( 56 + <DocLinkBlock {...props} /> 57 + )} 58 + </div> 59 + </BlockLayout> 56 60 </CardThemeProvider> 57 61 ); 58 62 }
+498
components/Blocks/PollBlock/index.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps, BlockLayout } 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 + <BlockLayout 65 + isSelected={!!isSelected} 66 + hasBackground={"accent"} 67 + className="poll flex flex-col gap-2 w-full" 68 + > 69 + {pollState === "editing" ? ( 70 + <EditPoll 71 + totalVotes={totalVotes} 72 + votes={votes.map((v) => v.poll_votes_on_entity)} 73 + entityID={props.entityID} 74 + close={() => { 75 + if (hasVoted) setPollState("results"); 76 + else setPollState("voting"); 77 + }} 78 + /> 79 + ) : pollState === "results" ? ( 80 + <PollResults 81 + entityID={props.entityID} 82 + pollState={pollState} 83 + setPollState={setPollState} 84 + hasVoted={!!hasVoted} 85 + /> 86 + ) : ( 87 + <PollVote 88 + entityID={props.entityID} 89 + onSubmit={() => setPollState("results")} 90 + pollState={pollState} 91 + setPollState={setPollState} 92 + hasVoted={!!hasVoted} 93 + /> 94 + )} 95 + </BlockLayout> 96 + ); 97 + }; 98 + 99 + const PollVote = (props: { 100 + entityID: string; 101 + onSubmit: () => void; 102 + pollState: "editing" | "voting" | "results"; 103 + setPollState: (pollState: "editing" | "voting" | "results") => void; 104 + hasVoted: boolean; 105 + }) => { 106 + let { data, mutate } = usePollData(); 107 + let { permissions } = useEntitySetContext(); 108 + 109 + let pollOptions = useEntity(props.entityID, "poll/options"); 110 + let currentVotes = data?.voter_token 111 + ? data.polls 112 + .filter( 113 + (p) => 114 + p.poll_votes_on_entity.poll_entity === props.entityID && 115 + p.poll_votes_on_entity.voter_token === data.voter_token, 116 + ) 117 + .map((v) => v.poll_votes_on_entity.option_entity) 118 + : []; 119 + let [selectedPollOptions, setSelectedPollOptions] = 120 + useState<string[]>(currentVotes); 121 + 122 + return ( 123 + <> 124 + {pollOptions.map((option, index) => ( 125 + <PollVoteButton 126 + key={option.data.value} 127 + selected={selectedPollOptions.includes(option.data.value)} 128 + toggleSelected={() => 129 + setSelectedPollOptions((s) => 130 + s.includes(option.data.value) 131 + ? s.filter((s) => s !== option.data.value) 132 + : [...s, option.data.value], 133 + ) 134 + } 135 + entityID={option.data.value} 136 + /> 137 + ))} 138 + <div className="flex justify-between items-center"> 139 + <div className="flex justify-end gap-2"> 140 + {permissions.write && ( 141 + <button 142 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 143 + onClick={() => { 144 + props.setPollState("editing"); 145 + }} 146 + > 147 + Edit Options 148 + </button> 149 + )} 150 + 151 + {permissions.write && <Separator classname="h-6" />} 152 + <PollStateToggle 153 + setPollState={props.setPollState} 154 + pollState={props.pollState} 155 + hasVoted={props.hasVoted} 156 + /> 157 + </div> 158 + <ButtonPrimary 159 + className="place-self-end" 160 + onClick={async () => { 161 + await voteOnPoll(props.entityID, selectedPollOptions); 162 + mutate((oldState) => { 163 + if (!oldState || !oldState.voter_token) return; 164 + return { 165 + ...oldState, 166 + polls: [ 167 + ...oldState.polls.filter( 168 + (p) => 169 + !( 170 + p.poll_votes_on_entity.voter_token === 171 + oldState.voter_token && 172 + p.poll_votes_on_entity.poll_entity == props.entityID 173 + ), 174 + ), 175 + ...selectedPollOptions.map((option_entity) => ({ 176 + poll_votes_on_entity: { 177 + option_entity, 178 + entities: { set: "" }, 179 + poll_entity: props.entityID, 180 + voter_token: oldState.voter_token!, 181 + }, 182 + })), 183 + ], 184 + }; 185 + }); 186 + props.onSubmit(); 187 + }} 188 + disabled={ 189 + selectedPollOptions.length === 0 || 190 + (selectedPollOptions.length === currentVotes.length && 191 + selectedPollOptions.every((s) => currentVotes.includes(s))) 192 + } 193 + > 194 + Vote! 195 + </ButtonPrimary> 196 + </div> 197 + </> 198 + ); 199 + }; 200 + const PollVoteButton = (props: { 201 + entityID: string; 202 + selected: boolean; 203 + toggleSelected: () => void; 204 + }) => { 205 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 206 + if (!optionName) return null; 207 + if (props.selected) 208 + return ( 209 + <div className="flex gap-2 items-center"> 210 + <ButtonPrimary 211 + className={`pollOption grow max-w-full flex`} 212 + onClick={() => { 213 + props.toggleSelected(); 214 + }} 215 + > 216 + {optionName} 217 + </ButtonPrimary> 218 + </div> 219 + ); 220 + return ( 221 + <div className="flex gap-2 items-center"> 222 + <ButtonSecondary 223 + className={`pollOption grow max-w-full flex`} 224 + onClick={() => { 225 + props.toggleSelected(); 226 + }} 227 + > 228 + {optionName} 229 + </ButtonSecondary> 230 + </div> 231 + ); 232 + }; 233 + 234 + const PollResults = (props: { 235 + entityID: string; 236 + pollState: "editing" | "voting" | "results"; 237 + setPollState: (pollState: "editing" | "voting" | "results") => void; 238 + hasVoted: boolean; 239 + }) => { 240 + let { data } = usePollData(); 241 + let { permissions } = useEntitySetContext(); 242 + let pollOptions = useEntity(props.entityID, "poll/options"); 243 + let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 244 + let votesByOptions = pollData?.votesByOption || {}; 245 + let highestVotes = Math.max(...Object.values(votesByOptions)); 246 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 247 + (winningEntities, [entity, votes]) => { 248 + if (votes === highestVotes) winningEntities.push(entity); 249 + return winningEntities; 250 + }, 251 + [], 252 + ); 253 + return ( 254 + <> 255 + {pollOptions.map((p) => ( 256 + <PollResult 257 + key={p.id} 258 + winner={winningOptionEntities.includes(p.data.value)} 259 + entityID={p.data.value} 260 + totalVotes={pollData?.unique_votes || 0} 261 + votes={pollData?.votesByOption[p.data.value] || 0} 262 + /> 263 + ))} 264 + <div className="flex gap-2"> 265 + {permissions.write && ( 266 + <button 267 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 268 + onClick={() => { 269 + props.setPollState("editing"); 270 + }} 271 + > 272 + Edit Options 273 + </button> 274 + )} 275 + 276 + {permissions.write && <Separator classname="h-6" />} 277 + <PollStateToggle 278 + setPollState={props.setPollState} 279 + pollState={props.pollState} 280 + hasVoted={props.hasVoted} 281 + /> 282 + </div> 283 + </> 284 + ); 285 + }; 286 + 287 + const PollResult = (props: { 288 + entityID: string; 289 + votes: number; 290 + totalVotes: number; 291 + winner: boolean; 292 + }) => { 293 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 294 + return ( 295 + <div 296 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 297 + > 298 + <div 299 + style={{ 300 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 301 + paintOrder: "stroke fill", 302 + }} 303 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 304 + > 305 + <div className="grow max-w-full truncate">{optionName}</div> 306 + <div>{props.votes}</div> 307 + </div> 308 + <div 309 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 310 + > 311 + <div 312 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 313 + style={{ 314 + maskImage: "var(--hatchSVG)", 315 + maskRepeat: "repeat repeat", 316 + 317 + ...(props.votes === 0 318 + ? { width: "4px" } 319 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 320 + }} 321 + /> 322 + <div /> 323 + </div> 324 + </div> 325 + ); 326 + }; 327 + 328 + const EditPoll = (props: { 329 + votes: { option_entity: string }[]; 330 + totalVotes: number; 331 + entityID: string; 332 + close: () => void; 333 + }) => { 334 + let pollOptions = useEntity(props.entityID, "poll/options"); 335 + let { rep } = useReplicache(); 336 + let permission_set = useEntitySetContext(); 337 + let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 338 + [k: string]: string; 339 + }>({}); 340 + return ( 341 + <> 342 + {props.totalVotes > 0 && ( 343 + <div className="text-sm italic text-tertiary"> 344 + You can&apos;t edit options people already voted for! 345 + </div> 346 + )} 347 + 348 + {pollOptions.length === 0 && ( 349 + <div className="text-center italic text-tertiary text-sm"> 350 + no options yet... 351 + </div> 352 + )} 353 + {pollOptions.map((p) => ( 354 + <EditPollOption 355 + key={p.id} 356 + entityID={p.data.value} 357 + pollEntity={props.entityID} 358 + disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 359 + localNameState={localPollOptionNames[p.data.value]} 360 + setLocalNameState={setLocalPollOptionNames} 361 + /> 362 + ))} 363 + 364 + <button 365 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 366 + onClick={async () => { 367 + let pollOptionEntity = v7(); 368 + await rep?.mutate.addPollOption({ 369 + pollEntity: props.entityID, 370 + pollOptionEntity, 371 + pollOptionName: "", 372 + permission_set: permission_set.set, 373 + factID: v7(), 374 + }); 375 + 376 + focusElement( 377 + document.getElementById( 378 + elementId.block(props.entityID).pollInput(pollOptionEntity), 379 + ) as HTMLInputElement | null, 380 + ); 381 + }} 382 + > 383 + Add an Option 384 + </button> 385 + 386 + <hr className="border-border" /> 387 + <ButtonPrimary 388 + className="place-self-end" 389 + onClick={async () => { 390 + // remove any poll options that have no name 391 + // look through the localPollOptionNames object and remove any options that have no name 392 + let emptyOptions = Object.entries(localPollOptionNames).filter( 393 + ([optionEntity, optionName]) => optionName === "", 394 + ); 395 + await Promise.all( 396 + emptyOptions.map( 397 + async ([entity]) => 398 + await rep?.mutate.removePollOption({ 399 + optionEntity: entity, 400 + }), 401 + ), 402 + ); 403 + 404 + await rep?.mutate.assertFact( 405 + Object.entries(localPollOptionNames) 406 + .filter(([, name]) => !!name) 407 + .map(([entity, name]) => ({ 408 + entity, 409 + attribute: "poll-option/name", 410 + data: { type: "string", value: name }, 411 + })), 412 + ); 413 + props.close(); 414 + }} 415 + > 416 + Save <CheckTiny /> 417 + </ButtonPrimary> 418 + </> 419 + ); 420 + }; 421 + 422 + const EditPollOption = (props: { 423 + entityID: string; 424 + pollEntity: string; 425 + localNameState: string | undefined; 426 + setLocalNameState: ( 427 + s: (s: { [k: string]: string }) => { [k: string]: string }, 428 + ) => void; 429 + disabled: boolean; 430 + }) => { 431 + let { rep } = useReplicache(); 432 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 433 + useEffect(() => { 434 + props.setLocalNameState((s) => ({ 435 + ...s, 436 + [props.entityID]: optionName || "", 437 + })); 438 + }, [optionName, props.setLocalNameState, props.entityID]); 439 + 440 + return ( 441 + <div className="flex gap-2 items-center"> 442 + <Input 443 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 444 + type="text" 445 + className="pollOptionInput w-full input-with-border" 446 + placeholder="Option here..." 447 + disabled={props.disabled} 448 + value={ 449 + props.localNameState === undefined ? optionName : props.localNameState 450 + } 451 + onChange={(e) => { 452 + props.setLocalNameState((s) => ({ 453 + ...s, 454 + [props.entityID]: e.target.value, 455 + })); 456 + }} 457 + onKeyDown={(e) => { 458 + if (e.key === "Backspace" && !e.currentTarget.value) { 459 + e.preventDefault(); 460 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 461 + } 462 + }} 463 + /> 464 + 465 + <button 466 + tabIndex={-1} 467 + disabled={props.disabled} 468 + className="text-accent-contrast disabled:text-border" 469 + onMouseDown={async () => { 470 + await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 471 + }} 472 + > 473 + <CloseTiny /> 474 + </button> 475 + </div> 476 + ); 477 + }; 478 + 479 + const PollStateToggle = (props: { 480 + setPollState: (pollState: "editing" | "voting" | "results") => void; 481 + hasVoted: boolean; 482 + pollState: "editing" | "voting" | "results"; 483 + }) => { 484 + return ( 485 + <button 486 + className="text-sm text-accent-contrast " 487 + onClick={() => { 488 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 489 + }} 490 + > 491 + {props.pollState === "voting" 492 + ? "See Results" 493 + : props.hasVoted 494 + ? "Change Vote" 495 + : "Back to Poll"} 496 + </button> 497 + ); 498 + };
+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 - };
+8 -10
components/Blocks/PublicationPollBlock.tsx
··· 1 1 import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 2 + import { BlockLayout, 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"; ··· 52 53 }, [publicationData, props.entityID]); 53 54 54 55 return ( 55 - <div 56 - className={`poll flex flex-col gap-2 p-3 w-full 57 - ${isSelected ? "block-border-selected " : "block-border"}`} 58 - style={{ 59 - backgroundColor: 60 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 61 - }} 56 + <BlockLayout 57 + className="poll flex flex-col gap-2" 58 + hasBackground={"accent"} 59 + isSelected={!!isSelected} 62 60 > 63 61 <EditPollForPublication 64 62 entityID={props.entityID} 65 63 isPublished={isPublished} 66 64 /> 67 - </div> 65 + </BlockLayout> 68 66 ); 69 67 }; 70 68
+6 -8
components/Blocks/RSVPBlock/index.tsx
··· 1 1 "use client"; 2 2 import { Database } from "supabase/database.types"; 3 - import { BlockProps } from "components/Blocks/Block"; 3 + import { BlockProps, BlockLayout } from "components/Blocks/Block"; 4 4 import { useState } from "react"; 5 5 import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 6 import { useRSVPData } from "components/PageSWRDataProvider"; ··· 29 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 30 ); 31 31 return ( 32 - <div 33 - className={`rsvp relative flex flex-col gap-1 border p-3 w-full rounded-lg place-items-center justify-center ${isSelected ? "block-border-selected " : "block-border"}`} 34 - style={{ 35 - backgroundColor: 36 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 37 - }} 32 + <BlockLayout 33 + isSelected={!!isSelected} 34 + hasBackground={"accent"} 35 + className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 38 36 > 39 37 <RSVPForm entityID={props.entityID} /> 40 - </div> 38 + </BlockLayout> 41 39 ); 42 40 } 43 41
+42 -37
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"; 9 + import { ProfilePopover } from "components/ProfilePopover"; 6 10 7 11 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 12 export function RenderYJSFragment({ ··· 60 64 ); 61 65 } 62 66 63 - if (node.constructor === XmlElement && node.nodeName === "hard_break") { 67 + if ( 68 + node.constructor === XmlElement && 69 + node.nodeName === "hard_break" 70 + ) { 64 71 return <br key={index} />; 65 72 } 66 73 74 + // Handle didMention inline nodes 75 + if ( 76 + node.constructor === XmlElement && 77 + node.nodeName === "didMention" 78 + ) { 79 + const did = node.getAttribute("did") || ""; 80 + const text = node.getAttribute("text") || ""; 81 + return ( 82 + <a 83 + href={didToBlueskyUrl(did)} 84 + target="_blank" 85 + rel="noopener noreferrer" 86 + key={index} 87 + className="mention" 88 + > 89 + {text} 90 + </a> 91 + ); 92 + } 93 + 94 + // Handle atMention inline nodes 95 + if ( 96 + node.constructor === XmlElement && 97 + node.nodeName === "atMention" 98 + ) { 99 + const atURI = node.getAttribute("atURI") || ""; 100 + const text = node.getAttribute("text") || ""; 101 + return ( 102 + <AtMentionLink key={index} atURI={atURI}> 103 + {text} 104 + </AtMentionLink> 105 + ); 106 + } 107 + 67 108 return null; 68 109 }) 69 110 )} ··· 101 142 } 102 143 }; 103 144 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 145 function attributesToStyle(d: Delta) { 118 146 let props = { 119 147 style: {}, ··· 143 171 144 172 return props; 145 173 } 146 - 147 - export function YJSFragmentToString( 148 - node: XmlElement | XmlText | XmlHook, 149 - ): string { 150 - if (node.constructor === XmlElement) { 151 - // Handle hard_break nodes specially 152 - if (node.nodeName === "hard_break") { 153 - return "\n"; 154 - } 155 - return node 156 - .toArray() 157 - .map((f) => YJSFragmentToString(f)) 158 - .join(""); 159 - } 160 - if (node.constructor === XmlText) { 161 - return (node.toDelta() as Delta[]) 162 - .map((d) => { 163 - return d.insert; 164 - }) 165 - .join(""); 166 - } 167 - return ""; 168 - }
+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,
+101 -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 mention"; 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: 172 + "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", 173 + alt: "", 174 + width: "16", 175 + height: "16", 176 + loading: "lazy", 177 + }, 178 + ], 179 + node.attrs.text, 180 + ]; 181 + } 182 + 183 + return [ 184 + "span", 185 + { 186 + class: className, 187 + "data-at-uri": node.attrs.atURI, 188 + }, 189 + node.attrs.text, 190 + ]; 191 + }, 192 + } as NodeSpec, 193 + didMention: { 194 + attrs: { 195 + did: {}, 196 + text: { default: "" }, 197 + }, 198 + group: "inline", 199 + inline: true, 200 + atom: true, 201 + selectable: true, 202 + draggable: true, 203 + parseDOM: [ 204 + { 205 + tag: "span.didMention", 206 + getAttrs(dom: HTMLElement) { 207 + return { 208 + did: dom.getAttribute("data-did"), 209 + text: dom.textContent || "", 210 + }; 211 + }, 212 + }, 213 + ], 214 + toDOM(node) { 215 + return [ 216 + "span", 217 + { 218 + class: "didMention mention", 219 + "data-did": node.attrs.did, 220 + }, 221 + node.attrs.text, 222 + ]; 223 + }, 224 + } as NodeSpec, 125 225 }, 126 226 }; 127 227 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";
+11 -5
components/Buttons.tsx
··· 38 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 39 bg-accent-1 disabled:bg-border-light 40 40 border border-accent-1 rounded-md disabled:border-border-light 41 - outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 41 + outline-2 outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 42 text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 43 flex gap-2 items-center justify-center shrink-0 44 44 ${className} ··· 77 77 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 78 bg-bg-page disabled:bg-border-light 79 79 border border-accent-contrast rounded-md 80 - outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 + outline-2 outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 81 text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 82 flex gap-2 items-center justify-center shrink-0 83 83 ${props.className} ··· 116 116 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 117 bg-transparent hover:bg-[var(--accent-light)] 118 118 border border-transparent rounded-md hover:border-[var(--accent-light)] 119 - outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 119 + outline-2 outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 120 text-base font-bold text-accent-contrast disabled:text-border 121 121 flex gap-2 items-center justify-center shrink-0 122 122 ${props.className} ··· 165 165 side={props.side ? props.side : undefined} 166 166 sideOffset={6} 167 167 alignOffset={12} 168 - className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 168 + className="z-10 rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 169 + style={{ 170 + backgroundColor: 171 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)", 172 + }} 169 173 > 170 174 {props.tooltipContent} 171 175 <RadixTooltip.Arrow ··· 175 179 viewBox="0 0 16 8" 176 180 > 177 181 <PopoverArrow 178 - arrowFill={theme.colors["border"]} 182 + arrowFill={ 183 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 85%)" 184 + } 179 185 arrowStroke="transparent" 180 186 /> 181 187 </RadixTooltip.Arrow>
+6 -3
components/Canvas.tsx
··· 170 170 171 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 172 let showComments = pubRecord.preferences?.showComments; 173 + let showMentions = pubRecord.preferences?.showMentions; 173 174 174 175 return ( 175 176 <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"> ··· 178 179 <CommentTiny className="text-border" /> โ€” 179 180 </div> 180 181 )} 181 - <div className="flex gap-1 text-tertiary items-center"> 182 - <QuoteTiny className="text-border" /> โ€” 183 - </div> 182 + {showComments && ( 183 + <div className="flex gap-1 text-tertiary items-center"> 184 + <QuoteTiny className="text-border" /> โ€” 185 + </div> 186 + )} 184 187 185 188 {!props.isSubpage && ( 186 189 <>
-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 + };
+14
components/Icons/ImageCoverImage.tsx
··· 1 + export const ImageCoverImage = () => ( 2 + <svg 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + xmlns="http://www.w3.org/2000/svg" 8 + > 9 + <path 10 + d="M20.1631 2.56445C21.8887 2.56481 23.2881 3.96378 23.2881 5.68945V18.3105C23.288 20.0361 21.8886 21.4362 20.1631 21.4365H3.83789C2.11225 21.4365 0.713286 20.0371 0.712891 18.3115V5.68945C0.712891 3.96356 2.112 2.56445 3.83789 2.56445H20.1631ZM1.96289 18.3115C1.96329 19.3467 2.8026 20.1865 3.83789 20.1865H20.1631C21.1982 20.1862 22.038 19.3457 22.0381 18.3105V15.8066H1.96289V18.3115ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5936 15.2383 18.0078C15.2383 18.422 14.9025 18.7578 14.4883 18.7578H3.81543C3.40138 18.7576 3.06543 18.4219 3.06543 18.0078C3.06546 17.5937 3.4014 17.258 3.81543 17.2578H14.4883ZM19.9775 10.9688C19.5515 11.5175 18.8232 11.7343 18.166 11.5088L16.3213 10.876C16.2238 10.8425 16.1167 10.8506 16.0254 10.8984L15.0215 11.4238C14.4872 11.7037 13.8413 11.6645 13.3447 11.3223L12.6826 10.8652L11.3467 12.2539L11.6924 12.4844C11.979 12.6758 12.0572 13.0635 11.8662 13.3506C11.6751 13.6377 11.2873 13.7151 11 13.5244L10.0312 12.8799L8.81152 12.0654L8.03027 12.8691C7.5506 13.3622 6.78589 13.4381 6.21875 13.0488C6.17033 13.0156 6.10738 13.0112 6.05469 13.0371L4.79883 13.6572C4.25797 13.9241 3.61321 13.8697 3.125 13.5156L2.26172 12.8887L1.96289 13.1572V14.5566H22.0381V10.1299L21.1738 9.42383L19.9775 10.9688ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0361L3.85938 12.5039C3.97199 12.5854 4.12044 12.5977 4.24512 12.5361L5.50098 11.917C5.95929 11.6908 6.50439 11.7294 6.92578 12.0186C6.99106 12.0633 7.07957 12.0548 7.13477 11.998L7.75488 11.3604L5.58984 9.91504L4.71094 10.7012ZM3.83789 3.81445C2.80236 3.81445 1.96289 4.65392 1.96289 5.68945V11.4805L4.8291 8.91895C5.18774 8.59891 5.70727 8.54436 6.12207 8.77344L6.20312 8.82324L10.2891 11.5498L16.3809 5.22754L16.46 5.15234C16.8692 4.80225 17.4773 4.78945 17.9023 5.13672L22.0381 8.51562V5.68945C22.0381 4.65414 21.1983 3.81481 20.1631 3.81445H3.83789ZM13.5625 9.95312L14.0547 10.293C14.1692 10.3717 14.3182 10.3809 14.4414 10.3164L15.4453 9.79102C15.841 9.58378 16.3051 9.54827 16.7275 9.69336L18.5723 10.3271C18.7238 10.3788 18.8921 10.3286 18.9902 10.2021L20.2061 8.63281L17.2002 6.17676L13.5625 9.95312ZM8.86328 4.8291C9.84255 4.82937 10.6366 5.62324 10.6367 6.60254C10.6365 7.58178 9.8425 8.37571 8.86328 8.37598C7.88394 8.37585 7.09004 7.58186 7.08984 6.60254C7.08997 5.62315 7.88389 4.82923 8.86328 4.8291ZM8.86328 5.8291C8.43618 5.82923 8.08997 6.17544 8.08984 6.60254C8.09004 7.02958 8.43622 7.37585 8.86328 7.37598C9.29022 7.37571 9.63652 7.02949 9.63672 6.60254C9.63659 6.17552 9.29026 5.82937 8.86328 5.8291Z" 11 + fill="currentColor" 12 + /> 13 + </svg> 14 + );
+1
components/Icons/ReplyTiny.tsx
··· 8 8 viewBox="0 0 16 16" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 fillRule="evenodd"
+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 = (
+116
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 + showMentions: boolean | undefined; 18 + 19 + share?: boolean; 20 + }) => { 21 + let smoker = useSmoker(); 22 + let interactionsAvailable = 23 + (props.quotesCount > 0 && props.showMentions !== false) || 24 + (props.showComments !== false && props.commentsCount > 0); 25 + 26 + const tagsCount = props.tags?.length || 0; 27 + 28 + return ( 29 + <div 30 + className={`flex gap-2 text-tertiary text-sm items-center self-start`} 31 + > 32 + {tagsCount === 0 ? null : ( 33 + <> 34 + <TagPopover tags={props.tags!} /> 35 + {interactionsAvailable || props.share ? ( 36 + <Separator classname="h-4!" /> 37 + ) : null} 38 + </> 39 + )} 40 + 41 + {props.showMentions === false || props.quotesCount === 0 ? null : ( 42 + <SpeedyLink 43 + aria-label="Post quotes" 44 + href={`${props.postUrl}?interactionDrawer=quotes`} 45 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 46 + > 47 + <QuoteTiny /> {props.quotesCount} 48 + </SpeedyLink> 49 + )} 50 + {props.showComments === false || props.commentsCount === 0 ? null : ( 51 + <SpeedyLink 52 + aria-label="Post comments" 53 + href={`${props.postUrl}?interactionDrawer=comments`} 54 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 55 + > 56 + <CommentTiny /> {props.commentsCount} 57 + </SpeedyLink> 58 + )} 59 + {interactionsAvailable && props.share ? ( 60 + <Separator classname="h-4! !min-h-0" /> 61 + ) : null} 62 + {props.share && ( 63 + <> 64 + <button 65 + id={`copy-post-link-${props.postUrl}`} 66 + className="flex gap-1 items-center hover:text-accent-contrast relative" 67 + onClick={(e) => { 68 + e.stopPropagation(); 69 + e.preventDefault(); 70 + let mouseX = e.clientX; 71 + let mouseY = e.clientY; 72 + 73 + if (!props.postUrl) return; 74 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 75 + 76 + smoker({ 77 + text: <strong>Copied Link!</strong>, 78 + position: { 79 + y: mouseY, 80 + x: mouseX, 81 + }, 82 + }); 83 + }} 84 + > 85 + Share 86 + </button> 87 + </> 88 + )} 89 + </div> 90 + ); 91 + }; 92 + 93 + const TagPopover = (props: { tags: string[] }) => { 94 + return ( 95 + <Popover 96 + className="p-2! max-w-xs" 97 + trigger={ 98 + <div className="relative flex gap-1 items-center hover:text-accent-contrast"> 99 + <TagTiny /> {props.tags.length} 100 + </div> 101 + } 102 + > 103 + <TagList tags={props.tags} className="text-secondary!" /> 104 + </Popover> 105 + ); 106 + }; 107 + 108 + const TagList = (props: { tags: string[]; className?: string }) => { 109 + return ( 110 + <div className="flex gap-1 flex-wrap"> 111 + {props.tags.map((tag, index) => ( 112 + <Tag name={tag} key={index} className={props.className} /> 113 + ))} 114 + </div> 115 + ); 116 + };
-94
components/Layout.tsx
··· 1 - "use client"; 2 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 - import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { PopoverArrow } from "./Icons/PopoverArrow"; 6 - import { PopoverOpenContext } from "./Popover"; 7 - import { useState } from "react"; 8 - 9 1 export const Separator = (props: { classname?: string }) => { 10 2 return <div className={`h-full border-r border-border ${props.classname}`} />; 11 - }; 12 - 13 - export const Menu = (props: { 14 - open?: boolean; 15 - trigger: React.ReactNode; 16 - children: React.ReactNode; 17 - align?: "start" | "end" | "center" | undefined; 18 - alignOffset?: number; 19 - side?: "top" | "bottom" | "right" | "left" | undefined; 20 - background?: string; 21 - border?: string; 22 - className?: string; 23 - onOpenChange?: (o: boolean) => void; 24 - asChild?: boolean; 25 - }) => { 26 - let [open, setOpen] = useState(props.open || false); 27 - return ( 28 - <DropdownMenu.Root 29 - onOpenChange={(o) => { 30 - setOpen(o); 31 - props.onOpenChange?.(o); 32 - }} 33 - open={props.open} 34 - > 35 - <PopoverOpenContext value={open}> 36 - <DropdownMenu.Trigger asChild={props.asChild}> 37 - {props.trigger} 38 - </DropdownMenu.Trigger> 39 - <DropdownMenu.Portal> 40 - <NestedCardThemeProvider> 41 - <DropdownMenu.Content 42 - side={props.side ? props.side : "bottom"} 43 - align={props.align ? props.align : "center"} 44 - alignOffset={props.alignOffset ? props.alignOffset : undefined} 45 - sideOffset={4} 46 - collisionPadding={16} 47 - className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 48 - > 49 - {props.children} 50 - <DropdownMenu.Arrow 51 - asChild 52 - width={16} 53 - height={8} 54 - viewBox="0 0 16 8" 55 - > 56 - <PopoverArrow 57 - arrowFill={ 58 - props.background 59 - ? props.background 60 - : theme.colors["bg-page"] 61 - } 62 - arrowStroke={ 63 - props.border ? props.border : theme.colors["border"] 64 - } 65 - /> 66 - </DropdownMenu.Arrow> 67 - </DropdownMenu.Content> 68 - </NestedCardThemeProvider> 69 - </DropdownMenu.Portal> 70 - </PopoverOpenContext> 71 - </DropdownMenu.Root> 72 - ); 73 - }; 74 - 75 - export const MenuItem = (props: { 76 - children?: React.ReactNode; 77 - className?: string; 78 - onSelect: (e: Event) => void; 79 - id?: string; 80 - }) => { 81 - return ( 82 - <DropdownMenu.Item 83 - id={props.id} 84 - onSelect={(event) => { 85 - props.onSelect(event); 86 - }} 87 - className={` 88 - menuItem 89 - z-10 py-1! px-2! 90 - flex gap-2 91 - ${props.className} 92 - `} 93 - > 94 - {props.children} 95 - </DropdownMenu.Item> 96 - ); 97 3 }; 98 4 99 5 export const ShortcutKey = (props: { children: React.ReactNode }) => {
+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 + }
+97
components/Menu.tsx
··· 1 + "use client"; 2 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 + import { theme } from "tailwind.config"; 4 + import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 + import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 + import { useState } from "react"; 8 + 9 + export const Menu = (props: { 10 + open?: boolean; 11 + trigger: React.ReactNode; 12 + children: React.ReactNode; 13 + align?: "start" | "end" | "center" | undefined; 14 + alignOffset?: number; 15 + side?: "top" | "bottom" | "right" | "left" | undefined; 16 + background?: string; 17 + border?: string; 18 + className?: string; 19 + onOpenChange?: (o: boolean) => void; 20 + asChild?: boolean; 21 + }) => { 22 + let [open, setOpen] = useState(props.open || false); 23 + 24 + return ( 25 + <DropdownMenu.Root 26 + onOpenChange={(o) => { 27 + setOpen(o); 28 + props.onOpenChange?.(o); 29 + }} 30 + open={props.open} 31 + > 32 + <PopoverOpenContext value={open}> 33 + <DropdownMenu.Trigger asChild={props.asChild}> 34 + {props.trigger} 35 + </DropdownMenu.Trigger> 36 + <DropdownMenu.Portal> 37 + <NestedCardThemeProvider> 38 + <DropdownMenu.Content 39 + side={props.side ? props.side : "bottom"} 40 + align={props.align ? props.align : "center"} 41 + alignOffset={props.alignOffset ? props.alignOffset : undefined} 42 + sideOffset={4} 43 + collisionPadding={16} 44 + className={` 45 + dropdownMenu z-20 p-1 46 + flex flex-col gap-0.5 47 + bg-bg-page 48 + border border-border rounded-md shadow-md 49 + ${props.className}`} 50 + > 51 + {props.children} 52 + <DropdownMenu.Arrow 53 + asChild 54 + width={16} 55 + height={8} 56 + viewBox="0 0 16 8" 57 + > 58 + <PopoverArrow 59 + arrowFill={ 60 + props.background 61 + ? props.background 62 + : theme.colors["bg-page"] 63 + } 64 + arrowStroke={ 65 + props.border ? props.border : theme.colors["border"] 66 + } 67 + /> 68 + </DropdownMenu.Arrow> 69 + </DropdownMenu.Content> 70 + </NestedCardThemeProvider> 71 + </DropdownMenu.Portal> 72 + </PopoverOpenContext> 73 + </DropdownMenu.Root> 74 + ); 75 + }; 76 + 77 + export const MenuItem = (props: { 78 + children?: React.ReactNode; 79 + className?: string; 80 + onSelect: (e: Event) => void; 81 + id?: string; 82 + }) => { 83 + return ( 84 + <DropdownMenu.Item 85 + id={props.id} 86 + onSelect={(event) => { 87 + props.onSelect(event); 88 + }} 89 + className={` 90 + menuItem 91 + ${props.className} 92 + `} 93 + > 94 + {props.children} 95 + </DropdownMenu.Item> 96 + ); 97 + };
+33
components/OAuthError.tsx
··· 1 + "use client"; 2 + 3 + import { OAuthSessionError } from "src/atproto-oauth"; 4 + 5 + export function OAuthErrorMessage({ 6 + error, 7 + className, 8 + }: { 9 + error: OAuthSessionError; 10 + className?: string; 11 + }) { 12 + const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(window.location.href)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`; 13 + 14 + return ( 15 + <div className={className}> 16 + <span>Your session has expired or is invalid. </span> 17 + <a href={signInUrl} className="underline font-bold whitespace-nowrap"> 18 + Sign in again 19 + </a> 20 + </div> 21 + ); 22 + } 23 + 24 + export function isOAuthSessionError( 25 + error: unknown, 26 + ): error is OAuthSessionError { 27 + return ( 28 + typeof error === "object" && 29 + error !== null && 30 + "type" in error && 31 + (error as OAuthSessionError).type === "oauth_session_expired" 32 + ); 33 + }
+7 -8
components/PageHeader.tsx
··· 1 1 "use client"; 2 2 import { useState, useEffect } from "react"; 3 + import { useCardBorderHidden } from "./Pages/useCardBorderHidden"; 3 4 4 - export const Header = (props: { 5 - children: React.ReactNode; 6 - cardBorderHidden: boolean; 7 - }) => { 5 + export const Header = (props: { children: React.ReactNode }) => { 6 + let cardBorderHidden = useCardBorderHidden(); 8 7 let [scrollPos, setScrollPos] = useState(0); 9 8 10 9 useEffect(() => { ··· 22 21 } 23 22 }, []); 24 23 25 - let headerBGColor = props.cardBorderHidden 24 + let headerBGColor = !cardBorderHidden 26 25 ? "var(--bg-leaflet)" 27 26 : "var(--bg-page)"; 28 27 ··· 54 53 style={ 55 54 scrollPos < 20 56 55 ? { 57 - backgroundColor: props.cardBorderHidden 56 + backgroundColor: !cardBorderHidden 58 57 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 58 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 - paddingLeft: props.cardBorderHidden 59 + paddingLeft: !cardBorderHidden 61 60 ? "4px" 62 61 : `calc(${scrollPos / 20}*4px)`, 63 - paddingRight: props.cardBorderHidden 62 + paddingRight: !cardBorderHidden 64 63 ? "8px" 65 64 : `calc(${scrollPos / 20}*8px)`, 66 65 }
+4 -21
components/PageLayouts/DashboardLayout.tsx
··· 25 25 import Link from "next/link"; 26 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 + import { Tab } from "components/Tab"; 28 29 29 30 export type DashboardState = { 30 31 display?: "grid" | "list"; ··· 133 134 }, 134 135 >(props: { 135 136 id: string; 136 - cardBorderHidden: boolean; 137 137 tabs: T; 138 138 defaultTab: keyof T; 139 139 currentPage: navPages; ··· 180 180 </div> 181 181 </MediaContents> 182 182 <div 183 - className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `} 183 + className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4 `} 184 184 ref={ref} 185 185 id="home-content" 186 186 > 187 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 188 <> 189 - <Header cardBorderHidden={props.cardBorderHidden}> 189 + <Header> 190 190 {headerState === "default" ? ( 191 191 <> 192 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 355 ); 356 356 }; 357 357 358 - function Tab(props: { 359 - name: string; 360 - selected: boolean; 361 - onSelect: () => void; 362 - href?: string; 363 - }) { 364 - return ( 365 - <div 366 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 367 - onClick={() => props.onSelect()} 368 - > 369 - {props.name} 370 - {props.href && <ExternalLinkTiny />} 371 - </div> 372 - ); 373 - } 374 - 375 358 const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 376 359 let { filter } = useDashboardState(); 377 360 let setState = useSetDashboardState(); ··· 469 452 type="text" 470 453 id="pubName" 471 454 size={1} 472 - placeholder="searchโ€ฆ" 455 + placeholder="search..." 473 456 value={props.searchValue} 474 457 onChange={(e) => { 475 458 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}`;
+4 -6
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"; ··· 34 34 return focusedPageID === props.entityID; 35 35 }); 36 36 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 - let cardBorderHidden = useCardBorderHidden(props.entityID); 38 37 39 38 let drawerOpen = useDrawerOpen(props.entityID); 40 39 return ( ··· 49 48 }} 50 49 id={elementId.page(props.entityID).container} 51 50 drawerOpen={!!drawerOpen} 52 - cardBorderHidden={!!cardBorderHidden} 53 51 isFocused={isFocused} 54 52 fullPageScroll={props.fullPageScroll} 55 53 pageType={pageType} ··· 77 75 id: string; 78 76 children: React.ReactNode; 79 77 pageOptions?: React.ReactNode; 80 - cardBorderHidden: boolean; 81 78 fullPageScroll: boolean; 82 79 isFocused?: boolean; 83 80 onClickAction?: (e: React.MouseEvent) => void; 84 81 pageType: "canvas" | "doc"; 85 82 drawerOpen: boolean | undefined; 86 83 }) => { 84 + const cardBorderHidden = useCardBorderHidden(); 87 85 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 88 86 return ( 89 87 // this div wraps the contents AND the page options. ··· 106 104 shrink-0 snap-center 107 105 overflow-y-scroll 108 106 ${ 109 - !props.cardBorderHidden && 107 + !cardBorderHidden && 110 108 `h-full border 111 109 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 112 110 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 113 111 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 114 112 } 115 - ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 113 + ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 116 114 ${props.fullPageScroll && "max-w-full "} 117 115 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 118 116 ${
+9 -31
components/Pages/PageOptions.tsx
··· 7 7 import { useReplicache } from "src/replicache"; 8 8 9 9 import { Media } from "../Media"; 10 - import { MenuItem, Menu } from "../Layout"; 10 + import { MenuItem, Menu } from "../Menu"; 11 11 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 12 import { PageShareMenu } from "./PageShareMenu"; 13 13 import { useUndoState } from "src/undoManager"; ··· 21 21 export const PageOptionButton = ({ 22 22 children, 23 23 secondary, 24 - cardBorderHidden, 25 24 className, 26 25 disabled, 27 26 ...props 28 27 }: { 29 28 children: React.ReactNode; 30 29 secondary?: boolean; 31 - cardBorderHidden: boolean | undefined; 32 30 className?: string; 33 31 disabled?: boolean; 34 32 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 33 + const cardBorderHidden = useCardBorderHidden(); 35 34 return ( 36 35 <button 37 36 className={` ··· 58 57 first: boolean | undefined; 59 58 isFocused: boolean; 60 59 }) => { 61 - let cardBorderHidden = useCardBorderHidden(props.entityID); 62 - 63 60 return ( 64 61 <div 65 62 className={`pageOptions w-fit z-10 66 63 ${props.isFocused ? "block" : "sm:hidden block"} 67 - absolute sm:-right-[20px] right-3 sm:top-3 top-0 64 + absolute sm:-right-[19px] right-3 sm:top-3 top-0 68 65 flex sm:flex-col flex-row-reverse gap-1 items-start`} 69 66 > 70 67 {!props.first && ( 71 68 <PageOptionButton 72 - cardBorderHidden={cardBorderHidden} 73 69 secondary 74 70 onClick={() => { 75 71 useUIState.getState().closePage(props.entityID); ··· 78 74 <CloseTiny /> 79 75 </PageOptionButton> 80 76 )} 81 - <OptionsMenu 82 - entityID={props.entityID} 83 - first={!!props.first} 84 - cardBorderHidden={cardBorderHidden} 85 - /> 86 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 77 + <OptionsMenu entityID={props.entityID} first={!!props.first} /> 78 + <UndoButtons /> 87 79 </div> 88 80 ); 89 81 }; 90 82 91 - export const UndoButtons = (props: { 92 - cardBorderHidden: boolean | undefined; 93 - }) => { 83 + export const UndoButtons = () => { 94 84 let undoState = useUndoState(); 95 85 let { undoManager } = useReplicache(); 96 86 return ( 97 87 <Media mobile> 98 88 {undoState.canUndo && ( 99 89 <div className="gap-1 flex sm:flex-col"> 100 - <PageOptionButton 101 - secondary 102 - cardBorderHidden={props.cardBorderHidden} 103 - onClick={() => undoManager.undo()} 104 - > 90 + <PageOptionButton secondary onClick={() => undoManager.undo()}> 105 91 <UndoTiny /> 106 92 </PageOptionButton> 107 93 108 94 <PageOptionButton 109 95 secondary 110 - cardBorderHidden={props.cardBorderHidden} 111 96 onClick={() => undoManager.undo()} 112 97 disabled={!undoState.canRedo} 113 98 > ··· 119 104 ); 120 105 }; 121 106 122 - export const OptionsMenu = (props: { 123 - entityID: string; 124 - first: boolean; 125 - cardBorderHidden: boolean | undefined; 126 - }) => { 107 + export const OptionsMenu = (props: { entityID: string; first: boolean }) => { 127 108 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 109 let { permissions } = useEntitySetContext(); 129 110 if (!permissions.write) return null; ··· 138 119 if (!open) setState("normal"); 139 120 }} 140 121 trigger={ 141 - <PageOptionButton 142 - cardBorderHidden={props.cardBorderHidden} 143 - className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 - > 122 + <PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8"> 145 123 <MoreOptionsTiny className="sm:rotate-90" /> 146 124 </PageOptionButton> 147 125 }
+158 -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 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> 53 66 </div> 54 - </div> 55 - <TextField 56 - className="text-xl font-bold outline-hidden bg-transparent" 57 - value={title} 58 - onChange={async (newTitle) => { 59 - await rep?.mutate.updatePublicationDraft({ 60 - title: newTitle, 61 - description, 62 - }); 63 - }} 64 - placeholder="Untitled" 65 - /> 66 - <TextField 67 - placeholder="add an optional description..." 68 - className="italic text-secondary outline-hidden bg-transparent" 69 - value={description} 70 - onChange={async (newDescription) => { 71 - await rep?.mutate.updatePublicationDraft({ 72 - title, 73 - description: newDescription, 74 - }); 75 - }} 76 - /> 77 - {pub.doc ? ( 78 - <div className="flex flex-row items-center gap-2 pt-3"> 79 - <p className="text-sm text-tertiary"> 80 - Published {publishedAt && timeAgo(publishedAt)} 81 - </p> 82 - <Separator classname="h-4" /> 83 - <Link 84 - target="_blank" 85 - className="text-sm" 86 - href={ 87 - pub.publications 88 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 89 - : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 90 - } 91 - > 92 - View Post 93 - </Link> 94 - </div> 95 - ) : ( 96 - <p className="text-sm text-tertiary pt-2">Draft</p> 97 - )} 98 - </div> 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 + {pubRecord?.preferences?.showMentions && ( 125 + <div className="flex gap-1 items-center"> 126 + <QuoteTiny />โ€” 127 + </div> 128 + )} 129 + {pubRecord?.preferences?.showComments && ( 130 + <div className="flex gap-1 items-center"> 131 + <CommentTiny />โ€” 132 + </div> 133 + )} 134 + </div> 135 + </> 136 + } 137 + /> 99 138 ); 100 139 }; 101 140 ··· 178 217 if (!pub) return null; 179 218 180 219 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> 220 + <PostHeaderLayout 221 + pubLink={ 222 + <div className="text-accent-contrast font-bold hover:no-underline"> 223 + {pub.publications?.name} 224 + </div> 225 + } 226 + postTitle={pub.title} 227 + postDescription={pub.description} 228 + postInfo={ 229 + pub.doc ? ( 230 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 231 + ) : ( 232 + <p>Draft</p> 233 + ) 234 + } 235 + /> 236 + ); 237 + }; 185 238 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> 239 + const AddTags = () => { 240 + let { data: pub } = useLeafletPublicationData(); 241 + let { rep } = useReplicache(); 242 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 194 243 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> 244 + // Get tags from Replicache local state or published document 245 + let replicacheTags = useSubscribe(rep, (tx) => 246 + tx.get<string[]>("publication_tags"), 247 + ); 248 + 249 + // Determine which tags to use - prioritize Replicache state 250 + let tags: string[] = []; 251 + if (Array.isArray(replicacheTags)) { 252 + tags = replicacheTags; 253 + } else if (record?.tags && Array.isArray(record.tags)) { 254 + tags = record.tags as string[]; 255 + } 256 + 257 + // Update tags in replicache local state 258 + const handleTagsChange = async (newTags: string[]) => { 259 + // Store tags in replicache for next publish/update 260 + await rep?.mutate.updatePublicationDraft({ 261 + tags: newTags, 262 + }); 263 + }; 264 + 265 + return ( 266 + <Popover 267 + className="p-2! w-full min-w-xs" 268 + trigger={ 269 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 270 + <TagTiny />{" "} 271 + {tags.length > 0 272 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 273 + : "Add Tags"} 200 274 </div> 201 - ) : ( 202 - <p className="text-sm text-tertiary pt-2">Draft</p> 203 - )} 204 - </div> 275 + } 276 + > 277 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 278 + </Popover> 205 279 ); 206 280 };
+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 -18
components/Pages/useCardBorderHidden.ts
··· 1 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 - import { PubLeafletPublication } from "lexicons/api"; 3 - import { useEntity, useReplicache } from "src/replicache"; 1 + import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider"; 4 2 5 - export function useCardBorderHidden(entityID: string | null) { 6 - let { rootEntity } = useReplicache(); 7 - let { data: pub } = useLeafletPublicationData(); 8 - let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 9 - 10 - let cardBorderHidden = 11 - useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 12 - if (!cardBorderHidden && !rootCardBorderHidden) { 13 - if (pub?.publications?.record) { 14 - let record = pub.publications.record as PubLeafletPublication.Record; 15 - return !record.theme?.showPageBackground; 16 - } 17 - return false; 18 - } 19 - return (cardBorderHidden || rootCardBorderHidden)?.data.value; 3 + export function useCardBorderHidden(entityID?: string | null) { 4 + return useCardBorderHiddenContext(); 20 5 }
+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 - };
+145
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 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 17 + 18 + export const PostListing = (props: Post) => { 19 + let pubRecord = props.publication?.pubRecord as 20 + | PubLeafletPublication.Record 21 + | undefined; 22 + 23 + let postRecord = props.documents.data as PubLeafletDocument.Record; 24 + let postUri = new AtUri(props.documents.uri); 25 + let uri = props.publication ? props.publication?.uri : props.documents.uri; 26 + 27 + // For standalone documents (no publication), pass isStandalone to get correct defaults 28 + let isStandalone = !pubRecord; 29 + let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 30 + let themeRecord = pubRecord?.theme || postRecord?.theme; 31 + let backgroundImage = 32 + themeRecord?.backgroundImage?.image?.ref && uri 33 + ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) 34 + : null; 35 + 36 + let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; 37 + let backgroundImageSize = themeRecord?.backgroundImage?.width || 500; 38 + 39 + let showPageBackground = pubRecord 40 + ? pubRecord?.theme?.showPageBackground 41 + : postRecord.theme?.showPageBackground ?? true; 42 + 43 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 44 + let comments = 45 + pubRecord?.preferences?.showComments === false 46 + ? 0 47 + : props.documents.comments_on_documents?.[0]?.count || 0; 48 + let tags = (postRecord?.tags as string[] | undefined) || []; 49 + 50 + // For standalone posts, link directly to the document 51 + let postHref = props.publication 52 + ? `${props.publication.href}/${postUri.rkey}` 53 + : `/p/${postUri.host}/${postUri.rkey}`; 54 + 55 + return ( 56 + <BaseThemeProvider {...theme} local> 57 + <div 58 + style={{ 59 + backgroundImage: backgroundImage 60 + ? `url(${backgroundImage})` 61 + : undefined, 62 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 63 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 64 + }} 65 + className={`no-underline! flex flex-row gap-2 w-full relative 66 + bg-bg-leaflet 67 + border border-border-light rounded-lg 68 + sm:p-2 p-2 selected-outline 69 + hover:outline-accent-contrast hover:border-accent-contrast 70 + `} 71 + > 72 + <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 73 + <div 74 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 75 + style={{ 76 + backgroundColor: showPageBackground 77 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 78 + : "transparent", 79 + }} 80 + > 81 + <h3 className="text-primary truncate">{postRecord.title}</h3> 82 + 83 + <p className="text-secondary italic">{postRecord.description}</p> 84 + <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"> 85 + {props.publication && pubRecord && ( 86 + <PubInfo 87 + href={props.publication.href} 88 + pubRecord={pubRecord} 89 + uri={props.publication.uri} 90 + /> 91 + )} 92 + <div className="flex flex-row justify-between gap-2 items-center w-full"> 93 + <PostInfo publishedAt={postRecord.publishedAt} /> 94 + <InteractionPreview 95 + postUrl={postHref} 96 + quotesCount={quotes} 97 + commentsCount={comments} 98 + tags={tags} 99 + showComments={pubRecord?.preferences?.showComments} 100 + showMentions={pubRecord?.preferences?.showMentions} 101 + share 102 + /> 103 + </div> 104 + </div> 105 + </div> 106 + </div> 107 + </BaseThemeProvider> 108 + ); 109 + }; 110 + 111 + const PubInfo = (props: { 112 + href: string; 113 + pubRecord: PubLeafletPublication.Record; 114 + uri: string; 115 + }) => { 116 + return ( 117 + <div className="flex flex-col md:w-auto shrink-0 w-full"> 118 + <hr className="md:hidden block border-border-light mb-2" /> 119 + <Link 120 + href={props.href} 121 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 122 + > 123 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 124 + {props.pubRecord.name} 125 + </Link> 126 + </div> 127 + ); 128 + }; 129 + 130 + const PostInfo = (props: { publishedAt: string | undefined }) => { 131 + let localizedDate = useLocalizedDate(props.publishedAt || "", { 132 + year: "numeric", 133 + month: "short", 134 + day: "numeric", 135 + }); 136 + return ( 137 + <div className="flex gap-2 items-center shrink-0 self-start"> 138 + {props.publishedAt && ( 139 + <> 140 + <div className="shrink-0">{localizedDate}</div> 141 + </> 142 + )} 143 + </div> 144 + ); 145 + };
+98
components/ProfilePopover.tsx
··· 1 + "use client"; 2 + import { Popover } from "./Popover"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { useRef, useState } from "react"; 6 + import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader"; 7 + import { SpeedyLink } from "./SpeedyLink"; 8 + import { Tooltip } from "./Tooltip"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + 11 + export const ProfilePopover = (props: { 12 + trigger: React.ReactNode; 13 + didOrHandle: string; 14 + }) => { 15 + const [isOpen, setIsOpen] = useState(false); 16 + let [isHovered, setIsHovered] = useState(false); 17 + const hoverTimeout = useRef<null | number>(null); 18 + 19 + const { data, isLoading } = useSWR( 20 + isHovered ? ["profile-data", props.didOrHandle] : null, 21 + async () => { 22 + const response = await callRPC("get_profile_data", { 23 + didOrHandle: props.didOrHandle, 24 + }); 25 + return response.result; 26 + }, 27 + ); 28 + 29 + return ( 30 + <Tooltip 31 + className="max-w-sm p-0! text-center" 32 + asChild 33 + trigger={ 34 + <a 35 + className="no-underline" 36 + href={`https://leaflet.pub/p/${props.didOrHandle}`} 37 + target="_blank" 38 + onPointerEnter={(e) => { 39 + if (hoverTimeout.current) { 40 + window.clearTimeout(hoverTimeout.current); 41 + } 42 + hoverTimeout.current = window.setTimeout(async () => { 43 + setIsHovered(true); 44 + }, 150); 45 + }} 46 + onPointerLeave={() => { 47 + if (isHovered) return; 48 + if (hoverTimeout.current) { 49 + window.clearTimeout(hoverTimeout.current); 50 + hoverTimeout.current = null; 51 + } 52 + setIsHovered(false); 53 + }} 54 + > 55 + {props.trigger} 56 + </a> 57 + } 58 + onOpenChange={setIsOpen} 59 + > 60 + {isLoading ? ( 61 + <div className="text-secondary p-4">Loading...</div> 62 + ) : data ? ( 63 + <div> 64 + <ProfileHeader 65 + profile={data.profile} 66 + publications={data.publications} 67 + popover 68 + /> 69 + <KnownFollowers viewer={data.profile.viewer} did={data.profile.did} /> 70 + </div> 71 + ) : ( 72 + <div className="text-secondary py-2 px-4">Profile not found</div> 73 + )} 74 + </Tooltip> 75 + ); 76 + }; 77 + 78 + let KnownFollowers = (props: { 79 + viewer: ProfileViewDetailed["viewer"]; 80 + did: string; 81 + }) => { 82 + if (!props.viewer?.knownFollowers) return null; 83 + let count = props.viewer.knownFollowers.count; 84 + return ( 85 + <> 86 + <hr className="border-border" /> 87 + Followed by{" "} 88 + <a 89 + className="hover:underline" 90 + href={`https://bsky.social/profile/${props.did}/known-followers`} 91 + target="_blank" 92 + > 93 + {props.viewer?.knownFollowers?.followers[0]?.displayName}{" "} 94 + {count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""} 95 + </a> 96 + </> 97 + ); 98 + };
+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 - }
+18
components/Tab.tsx
··· 1 + import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 2 + 3 + export const Tab = (props: { 4 + name: string; 5 + selected: boolean; 6 + onSelect: () => void; 7 + href?: string; 8 + }) => { 9 + return ( 10 + <div 11 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 + onClick={() => props.onSelect()} 13 + > 14 + {props.name} 15 + {props.href && <ExternalLinkTiny />} 16 + </div> 17 + ); 18 + };
+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 -5
components/ThemeManager/PageThemeSetter.tsx
··· 3 3 import { pickers, SectionArrow, setColorAttribute } from "./ThemeSetter"; 4 4 5 5 import { 6 - PageBackgroundPicker, 6 + SubpageBackgroundPicker, 7 7 PageThemePickers, 8 8 } from "./Pickers/PageThemePickers"; 9 9 import { useMemo, useState } from "react"; ··· 54 54 className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 55 55 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 56 56 > 57 - <PageBackgroundPicker 57 + <SubpageBackgroundPicker 58 58 entityID={props.entityID} 59 59 openPicker={openPicker} 60 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 61 - setValue={set("theme/card-background")} 60 + setOpenPicker={setOpenPicker} 62 61 /> 63 62 </div> 64 63 ··· 147 146 <div 148 147 className={ 149 148 pageBorderHidden 150 - ? "py-2 px-0 border border-transparent" 149 + ? "relative py-2 px-0 border border-transparent" 151 150 : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 152 151 } 153 152 style={
+6
components/ThemeManager/Pickers/ColorPicker.tsx
··· 21 21 22 22 export const ColorPicker = (props: { 23 23 label?: string; 24 + helpText?: string; 24 25 value: Color | undefined; 25 26 alpha?: boolean; 26 27 image?: boolean; ··· 116 117 <div className="w-full flex flex-col gap-2 px-1 pb-2"> 117 118 { 118 119 <> 120 + {props.helpText && ( 121 + <div className="text-sm leading-tight text-tertiary pl-7 -mt-2.5"> 122 + {props.helpText} 123 + </div> 124 + )} 119 125 <ColorArea 120 126 className="w-full h-[128px] rounded-md" 121 127 colorSpace="hsb"
+4 -4
components/ThemeManager/Pickers/ImagePicker.tsx
··· 73 73 }); 74 74 }} 75 75 > 76 - <div className="flex flex-col gap-2 w-full"> 76 + <div className="flex flex-col w-full"> 77 77 <div className="flex gap-2"> 78 78 <div 79 79 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 122 122 }} 123 123 > 124 124 <Slider.Track 125 - className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 125 + className={`${repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 126 126 ></Slider.Track> 127 127 <Slider.Thumb 128 128 className={` 129 129 flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 130 - ${repeat ? "bg-[#595959]" : " bg-[#C3C3C3] "} 131 - ${repeat && "shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]"} `} 130 + ${repeat ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : " bg-[#C3C3C3] "} 131 + `} 132 132 aria-label="Volume" 133 133 /> 134 134 </Slider.Root>
-162
components/ThemeManager/Pickers/LeafletBGPicker.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - ColorPicker as SpectrumColorPicker, 5 - parseColor, 6 - Color, 7 - ColorArea, 8 - ColorThumb, 9 - ColorSlider, 10 - Input, 11 - ColorField, 12 - SliderTrack, 13 - ColorSwatch, 14 - } from "react-aria-components"; 15 - import { pickers, setColorAttribute } from "../ThemeSetter"; 16 - import { thumbStyle } from "./ColorPicker"; 17 - import { ImageInput, ImageSettings } from "./ImagePicker"; 18 - import { useEntity, useReplicache } from "src/replicache"; 19 - import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 - import { Separator } from "components/Layout"; 21 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 24 - 25 - export const LeafletBGPicker = (props: { 26 - entityID: string; 27 - openPicker: pickers; 28 - thisPicker: pickers; 29 - setOpenPicker: (thisPicker: pickers) => void; 30 - closePicker: () => void; 31 - setValue: (c: Color) => void; 32 - }) => { 33 - let bgImage = useEntity(props.entityID, "theme/background-image"); 34 - let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 35 - let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 36 - let open = props.openPicker == props.thisPicker; 37 - let { rep } = useReplicache(); 38 - 39 - return ( 40 - <> 41 - <div className="bgPickerLabel flex justify-between place-items-center "> 42 - <div className="bgPickerColorLabel flex gap-2 items-center"> 43 - <button 44 - onClick={() => { 45 - if (props.openPicker === props.thisPicker) { 46 - props.setOpenPicker("null"); 47 - } else { 48 - props.setOpenPicker(props.thisPicker); 49 - } 50 - }} 51 - className="flex gap-2 items-center" 52 - > 53 - <ColorSwatch 54 - color={bgColor} 55 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 56 - style={{ 57 - backgroundImage: bgImage?.data.src 58 - ? `url(${bgImage.data.src})` 59 - : undefined, 60 - backgroundSize: "cover", 61 - }} 62 - /> 63 - <strong className={` "text-[#595959]`}>{"Background"}</strong> 64 - </button> 65 - 66 - <div className="flex"> 67 - {bgImage ? ( 68 - <div className={`"text-[#969696]`}>Image</div> 69 - ) : ( 70 - <> 71 - <ColorField className="w-fit gap-1" value={bgColor}> 72 - <Input 73 - onMouseDown={onMouseDown} 74 - onFocus={(e) => { 75 - e.currentTarget.setSelectionRange( 76 - 1, 77 - e.currentTarget.value.length, 78 - ); 79 - }} 80 - onPaste={(e) => { 81 - console.log(e); 82 - }} 83 - onKeyDown={(e) => { 84 - if (e.key === "Enter") { 85 - e.currentTarget.blur(); 86 - } else return; 87 - }} 88 - onBlur={(e) => { 89 - props.setValue(parseColor(e.currentTarget.value)); 90 - }} 91 - className={`w-[72px] bg-transparent outline-nonetext-[#595959]`} 92 - /> 93 - </ColorField> 94 - </> 95 - )} 96 - </div> 97 - </div> 98 - <div className="flex gap-1 justify-end grow text-[#969696]"> 99 - {bgImage && ( 100 - <button 101 - onClick={() => { 102 - if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 103 - if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 104 - }} 105 - > 106 - <DeleteSmall /> 107 - </button> 108 - )} 109 - <label> 110 - <BlockImageSmall /> 111 - <div className="hidden"> 112 - <ImageInput 113 - {...props} 114 - onChange={() => { 115 - props.setOpenPicker(props.thisPicker); 116 - }} 117 - /> 118 - </div> 119 - </label> 120 - </div> 121 - </div> 122 - {open && ( 123 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 124 - <SpectrumColorPicker 125 - value={bgColor} 126 - onChange={setColorAttribute( 127 - rep, 128 - props.entityID, 129 - )("theme/page-background")} 130 - > 131 - {bgImage ? ( 132 - <ImageSettings 133 - entityID={props.entityID} 134 - setValue={props.setValue} 135 - /> 136 - ) : ( 137 - <> 138 - <ColorArea 139 - className="w-full h-[128px] rounded-md" 140 - colorSpace="hsb" 141 - xChannel="saturation" 142 - yChannel="brightness" 143 - > 144 - <ColorThumb className={thumbStyle} /> 145 - </ColorArea> 146 - <ColorSlider 147 - colorSpace="hsb" 148 - className="w-full " 149 - channel="hue" 150 - > 151 - <SliderTrack className="h-2 w-full rounded-md"> 152 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 153 - </SliderTrack> 154 - </ColorSlider> 155 - </> 156 - )} 157 - </SpectrumColorPicker> 158 - </div> 159 - )} 160 - </> 161 - ); 162 - };
+353 -43
components/ThemeManager/Pickers/PageThemePickers.tsx
··· 51 51 <hr className="border-border-light w-full" /> 52 52 </> 53 53 )} 54 - <PageTextPicker 54 + <TextPickers 55 55 value={primaryValue} 56 56 setValue={set("theme/primary")} 57 57 openPicker={props.openPicker} ··· 61 61 ); 62 62 }; 63 63 64 - export const PageBackgroundPicker = (props: { 64 + // Page background picker for subpages - shows Page/Containers color with optional background image 65 + export const SubpageBackgroundPicker = (props: { 65 66 entityID: string; 66 - setValue: (c: Color) => void; 67 67 openPicker: pickers; 68 68 setOpenPicker: (p: pickers) => void; 69 - home?: boolean; 70 69 }) => { 70 + let { rep, rootEntity } = useReplicache(); 71 + let set = useMemo(() => { 72 + return setColorAttribute(rep, props.entityID); 73 + }, [rep, props.entityID]); 74 + 71 75 let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 72 76 let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 73 - let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 77 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 78 + let entityPageBorderHidden = useEntity( 79 + props.entityID, 80 + "theme/card-border-hidden", 81 + ); 82 + let pageBorderHidden = 83 + (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 84 + let hasPageBackground = !pageBorderHidden; 85 + 86 + // Label is "Page" when page background is visible, "Containers" when hidden 87 + let label = hasPageBackground ? "Page" : "Containers"; 88 + 89 + // If root page border is hidden, only show color picker (no image support) 90 + if (!hasPageBackground) { 91 + return ( 92 + <ColorPicker 93 + label={label} 94 + helpText={"Affects menus, tooltips and some block backgrounds"} 95 + value={pageValue} 96 + setValue={set("theme/card-background")} 97 + thisPicker="page" 98 + openPicker={props.openPicker} 99 + setOpenPicker={props.setOpenPicker} 100 + closePicker={() => props.setOpenPicker("null")} 101 + alpha 102 + /> 103 + ); 104 + } 74 105 75 106 return ( 76 107 <> 77 - {pageBGImage && pageBGImage !== null && ( 78 - <PageBackgroundImagePicker 79 - disabled={pageBorderHidden?.data.value} 108 + {pageBGImage && ( 109 + <SubpageBackgroundImagePicker 80 110 entityID={props.entityID} 81 - thisPicker={"page-background-image"} 82 111 openPicker={props.openPicker} 83 112 setOpenPicker={props.setOpenPicker} 84 - closePicker={() => props.setOpenPicker("null")} 85 - setValue={props.setValue} 86 - home={props.home} 113 + setValue={set("theme/card-background")} 87 114 /> 88 115 )} 89 116 <div className="relative"> 90 - <PageBackgroundColorPicker 91 - label={pageBorderHidden?.data.value ? "Menus" : "Page"} 117 + <ColorPicker 118 + label={label} 92 119 value={pageValue} 93 - setValue={props.setValue} 94 - thisPicker={"page"} 120 + setValue={set("theme/card-background")} 121 + thisPicker="page" 95 122 openPicker={props.openPicker} 96 123 setOpenPicker={props.setOpenPicker} 124 + closePicker={() => props.setOpenPicker("null")} 97 125 alpha 98 126 /> 99 - {(pageBGImage === null || 100 - (!pageBGImage && !pageBorderHidden?.data.value && !props.home)) && ( 101 - <label 102 - className={` 103 - hover:cursor-pointer text-[#969696] shrink-0 104 - absolute top-0 right-0 105 - `} 106 - > 127 + {!pageBGImage && ( 128 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 107 129 <BlockImageSmall /> 108 130 <div className="hidden"> 109 131 <ImageInput ··· 119 141 ); 120 142 }; 121 143 144 + const SubpageBackgroundImagePicker = (props: { 145 + entityID: string; 146 + openPicker: pickers; 147 + setOpenPicker: (p: pickers) => void; 148 + setValue: (c: Color) => void; 149 + }) => { 150 + let { rep } = useReplicache(); 151 + let bgImage = useEntity(props.entityID, "theme/card-background-image"); 152 + let bgRepeat = useEntity( 153 + props.entityID, 154 + "theme/card-background-image-repeat", 155 + ); 156 + let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 157 + let bgAlpha = 158 + useEntity(props.entityID, "theme/card-background-image-opacity")?.data 159 + .value || 1; 160 + let alphaColor = useMemo(() => { 161 + return parseColor(`rgba(0,0,0,${bgAlpha})`); 162 + }, [bgAlpha]); 163 + let open = props.openPicker === "page-background-image"; 164 + 165 + return ( 166 + <> 167 + <div className="bgPickerColorLabel flex gap-2 items-center"> 168 + <button 169 + onClick={() => { 170 + props.setOpenPicker(open ? "null" : "page-background-image"); 171 + }} 172 + className="flex gap-2 items-center grow" 173 + > 174 + <ColorSwatch 175 + color={bgColor} 176 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 177 + style={{ 178 + backgroundImage: bgImage?.data.src 179 + ? `url(${bgImage.data.src})` 180 + : undefined, 181 + backgroundPosition: "center", 182 + backgroundSize: "cover", 183 + }} 184 + /> 185 + <strong className="text-[#595959]">Page</strong> 186 + <div className="italic text-[#8C8C8C]">image</div> 187 + </button> 188 + 189 + <SpectrumColorPicker 190 + value={alphaColor} 191 + onChange={(c) => { 192 + let alpha = c.getChannelValue("alpha"); 193 + rep?.mutate.assertFact({ 194 + entity: props.entityID, 195 + attribute: "theme/card-background-image-opacity", 196 + data: { type: "number", value: alpha }, 197 + }); 198 + }} 199 + > 200 + <Separator classname="h-4! my-1 border-[#C3C3C3]!" /> 201 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 202 + <Input 203 + onMouseDown={onMouseDown} 204 + onFocus={(e) => { 205 + e.currentTarget.setSelectionRange( 206 + 0, 207 + e.currentTarget.value.length - 1, 208 + ); 209 + }} 210 + onKeyDown={(e) => { 211 + if (e.key === "Enter") { 212 + e.currentTarget.blur(); 213 + } else return; 214 + }} 215 + className="w-[48px] bg-transparent outline-hidden" 216 + /> 217 + </ColorField> 218 + </SpectrumColorPicker> 219 + 220 + <div className="flex gap-1 text-[#8C8C8C]"> 221 + <button 222 + onClick={() => { 223 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 224 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 225 + }} 226 + > 227 + <DeleteSmall /> 228 + </button> 229 + <label className="hover:cursor-pointer"> 230 + <BlockImageSmall /> 231 + <div className="hidden"> 232 + <ImageInput 233 + entityID={props.entityID} 234 + onChange={() => props.setOpenPicker("page-background-image")} 235 + card 236 + /> 237 + </div> 238 + </label> 239 + </div> 240 + </div> 241 + {open && ( 242 + <div className="pageImagePicker flex flex-col gap-2"> 243 + <ImageSettings 244 + entityID={props.entityID} 245 + card 246 + setValue={props.setValue} 247 + /> 248 + <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2"> 249 + <hr className="border-[#DBDBDB]" /> 250 + <SpectrumColorPicker 251 + value={alphaColor} 252 + onChange={(c) => { 253 + let alpha = c.getChannelValue("alpha"); 254 + rep?.mutate.assertFact({ 255 + entity: props.entityID, 256 + attribute: "theme/card-background-image-opacity", 257 + data: { type: "number", value: alpha }, 258 + }); 259 + }} 260 + > 261 + <ColorSlider 262 + colorSpace="hsb" 263 + className="w-full mt-1 rounded-full" 264 + style={{ 265 + backgroundImage: `url(/transparent-bg.png)`, 266 + backgroundRepeat: "repeat", 267 + backgroundSize: "8px", 268 + }} 269 + channel="alpha" 270 + > 271 + <SliderTrack className="h-2 w-full rounded-md"> 272 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 273 + </SliderTrack> 274 + </ColorSlider> 275 + </SpectrumColorPicker> 276 + </div> 277 + </div> 278 + )} 279 + </> 280 + ); 281 + }; 282 + 283 + // Unified background picker for leaflets - matches structure of BackgroundPicker for publications 284 + export const LeafletBackgroundPicker = (props: { 285 + entityID: string; 286 + openPicker: pickers; 287 + setOpenPicker: (p: pickers) => void; 288 + }) => { 289 + let { rep } = useReplicache(); 290 + let set = useMemo(() => { 291 + return setColorAttribute(rep, props.entityID); 292 + }, [rep, props.entityID]); 293 + 294 + let leafletBgValue = useColorAttribute( 295 + props.entityID, 296 + "theme/page-background", 297 + ); 298 + let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 299 + let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 300 + let leafletBGRepeat = useEntity( 301 + props.entityID, 302 + "theme/background-image-repeat", 303 + ); 304 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 305 + let hasPageBackground = !pageBorderHidden?.data.value; 306 + 307 + // When page background is hidden and no background image, only show the Background picker 308 + let showPagePicker = hasPageBackground || !!leafletBGImage; 309 + 310 + return ( 311 + <> 312 + {/* Background color/image picker */} 313 + {leafletBGImage ? ( 314 + <LeafletBackgroundImagePicker 315 + entityID={props.entityID} 316 + openPicker={props.openPicker} 317 + setOpenPicker={props.setOpenPicker} 318 + /> 319 + ) : ( 320 + <div className="relative"> 321 + <ColorPicker 322 + label="Background" 323 + value={leafletBgValue} 324 + setValue={set("theme/page-background")} 325 + thisPicker="leaflet" 326 + openPicker={props.openPicker} 327 + setOpenPicker={props.setOpenPicker} 328 + closePicker={() => props.setOpenPicker("null")} 329 + /> 330 + <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0"> 331 + <BlockImageSmall /> 332 + <div className="hidden"> 333 + <ImageInput 334 + entityID={props.entityID} 335 + onChange={() => props.setOpenPicker("leaflet")} 336 + /> 337 + </div> 338 + </label> 339 + </div> 340 + )} 341 + 342 + {/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */} 343 + {showPagePicker && ( 344 + <ColorPicker 345 + label={hasPageBackground ? "Page" : "Containers"} 346 + helpText={ 347 + hasPageBackground 348 + ? undefined 349 + : "Affects menus, tooltips and some block backgrounds" 350 + } 351 + value={pageValue} 352 + setValue={set("theme/card-background")} 353 + thisPicker="page" 354 + openPicker={props.openPicker} 355 + setOpenPicker={props.setOpenPicker} 356 + closePicker={() => props.setOpenPicker("null")} 357 + alpha 358 + /> 359 + )} 360 + 361 + <hr className="border-[#CCCCCC]" /> 362 + 363 + {/* Page Background toggle */} 364 + <PageBorderHider 365 + entityID={props.entityID} 366 + openPicker={props.openPicker} 367 + setOpenPicker={props.setOpenPicker} 368 + /> 369 + </> 370 + ); 371 + }; 372 + 373 + const LeafletBackgroundImagePicker = (props: { 374 + entityID: string; 375 + openPicker: pickers; 376 + setOpenPicker: (p: pickers) => void; 377 + }) => { 378 + let { rep } = useReplicache(); 379 + let bgImage = useEntity(props.entityID, "theme/background-image"); 380 + let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat"); 381 + let bgColor = useColorAttribute(props.entityID, "theme/page-background"); 382 + let open = props.openPicker === "leaflet"; 383 + 384 + return ( 385 + <> 386 + <div className="bgPickerColorLabel flex gap-2 items-center"> 387 + <button 388 + onClick={() => { 389 + props.setOpenPicker(open ? "null" : "leaflet"); 390 + }} 391 + className="flex gap-2 items-center grow" 392 + > 393 + <ColorSwatch 394 + color={bgColor} 395 + className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]" 396 + style={{ 397 + backgroundImage: bgImage?.data.src 398 + ? `url(${bgImage.data.src})` 399 + : undefined, 400 + backgroundPosition: "center", 401 + backgroundSize: "cover", 402 + }} 403 + /> 404 + <strong className="text-[#595959]">Background</strong> 405 + <div className="italic text-[#8C8C8C]">image</div> 406 + </button> 407 + <div className="flex gap-1 text-[#8C8C8C]"> 408 + <button 409 + onClick={() => { 410 + if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id }); 411 + if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id }); 412 + }} 413 + > 414 + <DeleteSmall /> 415 + </button> 416 + <label className="hover:cursor-pointer"> 417 + <BlockImageSmall /> 418 + <div className="hidden"> 419 + <ImageInput 420 + entityID={props.entityID} 421 + onChange={() => props.setOpenPicker("leaflet")} 422 + /> 423 + </div> 424 + </label> 425 + </div> 426 + </div> 427 + {open && ( 428 + <div className="pageImagePicker flex flex-col gap-2"> 429 + <ImageSettings entityID={props.entityID} setValue={() => {}} /> 430 + </div> 431 + )} 432 + </> 433 + ); 434 + }; 435 + 122 436 export const PageBackgroundColorPicker = (props: { 123 437 disabled?: boolean; 124 438 label: string; ··· 128 442 setValue: (c: Color) => void; 129 443 value: Color; 130 444 alpha?: boolean; 445 + helpText?: string; 131 446 }) => { 132 447 return ( 133 448 <ColorPicker 134 449 disabled={props.disabled} 135 450 label={props.label} 451 + helpText={props.helpText} 136 452 value={props.value} 137 453 setValue={props.setValue} 138 454 thisPicker={"page"} ··· 347 663 ); 348 664 }; 349 665 350 - export const PageTextPicker = (props: { 666 + export const TextPickers = (props: { 351 667 openPicker: pickers; 352 668 setOpenPicker: (thisPicker: pickers) => void; 353 669 value: Color; ··· 394 710 395 711 return ( 396 712 <> 397 - <div className="flex gap-2 items-center"> 398 - <Toggle 399 - toggleOn={!pageBorderHidden} 400 - setToggleOn={() => { 401 - handleToggle(); 402 - }} 403 - disabledColor1="#8C8C8C" 404 - disabledColor2="#DBDBDB" 405 - /> 406 - <button 407 - className="flex gap-2 items-center" 408 - onClick={() => { 409 - handleToggle(); 410 - }} 411 - > 713 + <Toggle 714 + toggle={!pageBorderHidden} 715 + onToggle={() => { 716 + handleToggle(); 717 + }} 718 + disabledColor1="#8C8C8C" 719 + disabledColor2="#DBDBDB" 720 + > 721 + <div className="flex gap-2"> 412 722 <div className="font-bold">Page Background</div> 413 723 <div className="italic text-[#8C8C8C]"> 414 - {pageBorderHidden ? "hidden" : ""} 724 + {pageBorderHidden ? "none" : ""} 415 725 </div> 416 - </button> 417 - </div> 726 + </div> 727 + </Toggle> 418 728 </> 419 729 ); 420 730 };
+215
components/ThemeManager/Pickers/PageWidthSetter.tsx
··· 1 + import * as Slider from "@radix-ui/react-slider"; 2 + import { Input } from "components/Input"; 3 + import { Radio } from "components/Checkbox"; 4 + import { useEntity, useReplicache } from "src/replicache"; 5 + import { pickers } from "../ThemeSetter"; 6 + import { useState, useEffect } from "react"; 7 + 8 + export const PageWidthSetter = (props: { 9 + entityID: string; 10 + openPicker: pickers; 11 + thisPicker: pickers; 12 + setOpenPicker: (thisPicker: pickers) => void; 13 + closePicker: () => void; 14 + }) => { 15 + let { rep } = useReplicache(); 16 + 17 + let defaultPreset = 624; 18 + let widePreset = 768; 19 + let pageWidth = useEntity(props.entityID, "theme/page-width")?.data.value; 20 + let currentValue = pageWidth || defaultPreset; 21 + let [interimValue, setInterimValue] = useState<number>(currentValue); 22 + let [selectedPreset, setSelectedPreset] = useState< 23 + "default" | "wide" | "custom" 24 + >( 25 + currentValue === defaultPreset 26 + ? "default" 27 + : currentValue === widePreset 28 + ? "wide" 29 + : "custom", 30 + ); 31 + let min = 320; 32 + let max = 1200; 33 + 34 + let open = props.openPicker == props.thisPicker; 35 + 36 + // Update interim value when current value changes 37 + useEffect(() => { 38 + setInterimValue(currentValue); 39 + }, [currentValue]); 40 + 41 + const setPageWidth = (value: number) => { 42 + rep?.mutate.assertFact({ 43 + entity: props.entityID, 44 + attribute: "theme/page-width", 45 + data: { 46 + type: "number", 47 + value: value, 48 + }, 49 + }); 50 + }; 51 + 52 + return ( 53 + <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md"> 54 + <div className="flex flex-col gap-2"> 55 + <div className="flex gap-2 items-center"> 56 + <button 57 + className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 items-start text-left" 58 + onClick={() => { 59 + if (props.openPicker === props.thisPicker) { 60 + props.setOpenPicker("null"); 61 + } else { 62 + props.setOpenPicker(props.thisPicker); 63 + } 64 + }} 65 + > 66 + Max Page Width 67 + <span className="flex font-normal text-[#969696]"> 68 + {currentValue}px 69 + </span> 70 + </button> 71 + </div> 72 + {open && ( 73 + <div className="flex flex-col gap-1 px-3"> 74 + <label htmlFor="default" className="w-full"> 75 + <Radio 76 + radioCheckedClassName="text-[#595959]!" 77 + radioEmptyClassName="text-[#969696]!" 78 + type="radio" 79 + id="default" 80 + name="page-width-options" 81 + value="default" 82 + checked={selectedPreset === "default"} 83 + onChange={(e) => { 84 + if (!e.currentTarget.checked) return; 85 + setSelectedPreset("default"); 86 + setPageWidth(defaultPreset); 87 + }} 88 + > 89 + <div 90 + className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 91 + > 92 + default (624px) 93 + </div> 94 + </Radio> 95 + </label> 96 + <label htmlFor="wide" className="w-full"> 97 + <Radio 98 + radioCheckedClassName="text-[#595959]!" 99 + radioEmptyClassName="text-[#969696]!" 100 + type="radio" 101 + id="wide" 102 + name="page-width-options" 103 + value="wide" 104 + checked={selectedPreset === "wide"} 105 + onChange={(e) => { 106 + if (!e.currentTarget.checked) return; 107 + setSelectedPreset("wide"); 108 + setPageWidth(widePreset); 109 + }} 110 + > 111 + <div 112 + className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 113 + > 114 + wide (756px) 115 + </div> 116 + </Radio> 117 + </label> 118 + <label htmlFor="custom" className="pb-3 w-full"> 119 + <Radio 120 + type="radio" 121 + id="custom" 122 + name="page-width-options" 123 + value="custom" 124 + radioCheckedClassName="text-[#595959]!" 125 + radioEmptyClassName="text-[#969696]!" 126 + checked={selectedPreset === "custom"} 127 + onChange={(e) => { 128 + if (!e.currentTarget.checked) return; 129 + setSelectedPreset("custom"); 130 + if (selectedPreset !== "custom") { 131 + setPageWidth(currentValue); 132 + setInterimValue(currentValue); 133 + } 134 + }} 135 + > 136 + <div className="flex flex-col w-full"> 137 + <div className="flex gap-2"> 138 + <div 139 + className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`} 140 + > 141 + custom 142 + </div> 143 + <div 144 + className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`} 145 + > 146 + <Input 147 + type="number" 148 + className="w-10 text-right appearance-none bg-transparent" 149 + max={max} 150 + min={min} 151 + value={interimValue} 152 + onChange={(e) => { 153 + setInterimValue(parseInt(e.currentTarget.value)); 154 + }} 155 + onKeyDown={(e) => { 156 + if (e.key === "Enter" || e.key === "Escape") { 157 + e.preventDefault(); 158 + let clampedValue = interimValue; 159 + if (!isNaN(interimValue)) { 160 + clampedValue = Math.max( 161 + min, 162 + Math.min(max, interimValue), 163 + ); 164 + setInterimValue(clampedValue); 165 + } 166 + setPageWidth(clampedValue); 167 + } 168 + }} 169 + onBlur={() => { 170 + let clampedValue = interimValue; 171 + if (!isNaN(interimValue)) { 172 + clampedValue = Math.max( 173 + min, 174 + Math.min(max, interimValue), 175 + ); 176 + setInterimValue(clampedValue); 177 + } 178 + setPageWidth(clampedValue); 179 + }} 180 + /> 181 + px 182 + </div> 183 + </div> 184 + <Slider.Root 185 + className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`} 186 + value={[interimValue]} 187 + max={max} 188 + min={min} 189 + step={16} 190 + onValueChange={(value) => { 191 + setInterimValue(value[0]); 192 + }} 193 + onValueCommit={(value) => { 194 + setPageWidth(value[0]); 195 + }} 196 + > 197 + <Slider.Track 198 + className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 199 + /> 200 + <Slider.Thumb 201 + className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 202 + ${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"} 203 + `} 204 + aria-label="Max Page Width" 205 + /> 206 + </Slider.Root> 207 + </div> 208 + </Radio> 209 + </label> 210 + </div> 211 + )} 212 + </div> 213 + </div> 214 + ); 215 + };
+30 -24
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 24 24 hasPageBackground: boolean; 25 25 setHasPageBackground: (s: boolean) => void; 26 26 }) => { 27 + // When showPageBackground is false (hasPageBackground=false) and no background image, show leafletBg picker 28 + let showLeafletBgPicker = !props.hasPageBackground && !props.bgImage; 29 + 27 30 return ( 28 31 <> 29 32 {props.bgImage && props.bgImage !== null ? ( ··· 83 86 )} 84 87 </div> 85 88 )} 86 - <PageBackgroundColorPicker 87 - label={"Containers"} 88 - value={props.pageBackground} 89 - setValue={props.setPageBackground} 90 - thisPicker={"page"} 91 - openPicker={props.openPicker} 92 - setOpenPicker={props.setOpenPicker} 93 - alpha={props.hasPageBackground ? true : false} 94 - /> 89 + {!showLeafletBgPicker && ( 90 + // When there's a background image and page background hidden, label should say "Containers" 91 + <PageBackgroundColorPicker 92 + label={props.hasPageBackground ? "Page" : "Containers"} 93 + helpText={ 94 + props.hasPageBackground 95 + ? undefined 96 + : "Affects menus, tooltips and some block backgrounds" 97 + } 98 + value={props.pageBackground} 99 + setValue={props.setPageBackground} 100 + thisPicker={"page"} 101 + openPicker={props.openPicker} 102 + setOpenPicker={props.setOpenPicker} 103 + alpha={props.hasPageBackground ? true : false} 104 + /> 105 + )} 95 106 <hr className="border-border-light" /> 96 107 <div className="flex gap-2 items-center"> 97 108 <Toggle 98 - toggleOn={props.hasPageBackground} 99 - setToggleOn={() => { 109 + toggle={props.hasPageBackground} 110 + onToggle={() => { 100 111 props.setHasPageBackground(!props.hasPageBackground); 101 112 props.hasPageBackground && 102 113 props.openPicker === "page" && ··· 104 115 }} 105 116 disabledColor1="#8C8C8C" 106 117 disabledColor2="#DBDBDB" 107 - /> 108 - <button 109 - className="flex gap-2 items-center" 110 - onClick={() => { 111 - props.setHasPageBackground(!props.hasPageBackground); 112 - props.hasPageBackground && props.setOpenPicker("null"); 113 - }} 114 118 > 115 - <div className="font-bold">Page Background</div> 116 - <div className="italic text-[#8C8C8C]"> 117 - {props.hasPageBackground ? "" : "hidden"} 119 + <div className="flex gap-2"> 120 + <div className="font-bold">Page Background</div> 121 + <div className="italic text-[#8C8C8C]"> 122 + {props.hasPageBackground ? "" : "none"} 123 + </div> 118 124 </div> 119 - </button> 125 + </Toggle> 120 126 </div> 121 127 </> 122 128 ); ··· 250 256 props.setBgImage({ ...props.bgImage, repeat: 500 }); 251 257 }} 252 258 > 253 - <div className="flex flex-col gap-2 w-full"> 259 + <div className="flex flex-col w-full"> 254 260 <div className="flex gap-2"> 255 261 <div 256 262 className={`shink-0 grow-0 w-fit z-10 cursor-pointer ${props.bgImage?.repeat ? "text-[#595959]" : " text-[#969696]"}`} ··· 289 295 }} 290 296 > 291 297 <Slider.Track 292 - className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px]`} 298 + className={`${props.bgImage?.repeat ? "bg-[#595959]" : " bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 293 299 ></Slider.Track> 294 300 <Slider.Thumb 295 301 className={`
+201
components/ThemeManager/PubPickers/PubPageWidthSetter.tsx
··· 1 + import * as Slider from "@radix-ui/react-slider"; 2 + import { Input } from "components/Input"; 3 + import { Radio } from "components/Checkbox"; 4 + import { useState, useEffect } from "react"; 5 + import { pickers } from "../ThemeSetter"; 6 + 7 + export const PubPageWidthSetter = (props: { 8 + pageWidth: number | undefined; 9 + setPageWidth: (value: number) => void; 10 + thisPicker: pickers; 11 + openPicker: pickers; 12 + setOpenPicker: (p: pickers) => void; 13 + }) => { 14 + let defaultPreset = 624; 15 + let widePreset = 768; 16 + 17 + let currentValue = props.pageWidth || defaultPreset; 18 + let [interimValue, setInterimValue] = useState<number>(currentValue); 19 + let [selectedPreset, setSelectedPreset] = useState< 20 + "default" | "wide" | "custom" 21 + >( 22 + currentValue === defaultPreset 23 + ? "default" 24 + : currentValue === widePreset 25 + ? "wide" 26 + : "custom", 27 + ); 28 + let min = 320; 29 + let max = 1200; 30 + 31 + // Update interim value when current value changes 32 + useEffect(() => { 33 + setInterimValue(currentValue); 34 + }, [currentValue]); 35 + 36 + const setPageWidth = (value: number) => { 37 + props.setPageWidth(value); 38 + }; 39 + 40 + let open = props.openPicker == props.thisPicker; 41 + 42 + return ( 43 + <div className="pageWidthSetter flex flex-col gap-2 px-2 py-[6px] border border-[#CCCCCC] rounded-md bg-white"> 44 + <button 45 + type="button" 46 + className="font-bold text-[#000000] shrink-0 grow-0 w-full flex gap-2 text-left items-center" 47 + onClick={() => { 48 + if (!open) { 49 + props.setOpenPicker(props.thisPicker); 50 + } else { 51 + props.setOpenPicker("null"); 52 + } 53 + }} 54 + > 55 + Max Page Width 56 + <div className="flex font-normal text-[#969696]">{currentValue}px</div> 57 + </button> 58 + 59 + {open && ( 60 + <div className="flex flex-col gap-1 px-3"> 61 + <label htmlFor="pub-default" className="w-full"> 62 + <Radio 63 + radioCheckedClassName="text-[#595959]!" 64 + radioEmptyClassName="text-[#969696]!" 65 + type="radio" 66 + id="pub-default" 67 + name="pub-page-width-options" 68 + value="default" 69 + checked={selectedPreset === "default"} 70 + onChange={(e) => { 71 + if (!e.currentTarget.checked) return; 72 + setSelectedPreset("default"); 73 + setPageWidth(defaultPreset); 74 + }} 75 + > 76 + <div 77 + className={`w-full cursor-pointer ${selectedPreset === "default" ? "text-[#595959]" : "text-[#969696]"}`} 78 + > 79 + default (624px) 80 + </div> 81 + </Radio> 82 + </label> 83 + <label htmlFor="pub-wide" className="w-full"> 84 + <Radio 85 + radioCheckedClassName="text-[#595959]!" 86 + radioEmptyClassName="text-[#969696]!" 87 + type="radio" 88 + id="pub-wide" 89 + name="pub-page-width-options" 90 + value="wide" 91 + checked={selectedPreset === "wide"} 92 + onChange={(e) => { 93 + if (!e.currentTarget.checked) return; 94 + setSelectedPreset("wide"); 95 + setPageWidth(widePreset); 96 + }} 97 + > 98 + <div 99 + className={`w-full cursor-pointer ${selectedPreset === "wide" ? "text-[#595959]" : "text-[#969696]"}`} 100 + > 101 + wide (756px) 102 + </div> 103 + </Radio> 104 + </label> 105 + <label htmlFor="pub-custom" className="pb-3 w-full"> 106 + <Radio 107 + type="radio" 108 + id="pub-custom" 109 + name="pub-page-width-options" 110 + value="custom" 111 + radioCheckedClassName="text-[#595959]!" 112 + radioEmptyClassName="text-[#969696]!" 113 + checked={selectedPreset === "custom"} 114 + onChange={(e) => { 115 + if (!e.currentTarget.checked) return; 116 + setSelectedPreset("custom"); 117 + if (selectedPreset !== "custom") { 118 + setPageWidth(currentValue); 119 + setInterimValue(currentValue); 120 + } 121 + }} 122 + > 123 + <div className="flex flex-col w-full"> 124 + <div className="flex gap-2"> 125 + <div 126 + className={`shrink-0 grow-0 w-fit z-10 cursor-pointer ${selectedPreset === "custom" ? "text-[#595959]" : "text-[#969696]"}`} 127 + > 128 + custom 129 + </div> 130 + <div 131 + className={`flex font-normal ${selectedPreset === "custom" ? "text-[#969696]" : "text-[#C3C3C3]"}`} 132 + > 133 + <Input 134 + type="number" 135 + className="w-10 text-right appearance-none bg-transparent" 136 + max={max} 137 + min={min} 138 + value={interimValue} 139 + onChange={(e) => { 140 + setInterimValue(parseInt(e.currentTarget.value)); 141 + }} 142 + onKeyDown={(e) => { 143 + if (e.key === "Enter" || e.key === "Escape") { 144 + e.preventDefault(); 145 + let clampedValue = interimValue; 146 + if (!isNaN(interimValue)) { 147 + clampedValue = Math.max( 148 + min, 149 + Math.min(max, interimValue), 150 + ); 151 + setInterimValue(clampedValue); 152 + } 153 + setPageWidth(clampedValue); 154 + } 155 + }} 156 + onBlur={() => { 157 + let clampedValue = interimValue; 158 + if (!isNaN(interimValue)) { 159 + clampedValue = Math.max( 160 + min, 161 + Math.min(max, interimValue), 162 + ); 163 + setInterimValue(clampedValue); 164 + } 165 + setPageWidth(clampedValue); 166 + }} 167 + /> 168 + px 169 + </div> 170 + </div> 171 + <Slider.Root 172 + className={`relative grow flex items-center select-none touch-none w-full h-fit px-1`} 173 + value={[interimValue]} 174 + max={max} 175 + min={min} 176 + step={16} 177 + onValueChange={(value) => { 178 + setInterimValue(value[0]); 179 + }} 180 + onValueCommit={(value) => { 181 + setPageWidth(value[0]); 182 + }} 183 + > 184 + <Slider.Track 185 + className={`${selectedPreset === "custom" ? "bg-[#595959]" : "bg-[#C3C3C3]"} relative grow rounded-full h-[3px] my-2`} 186 + /> 187 + <Slider.Thumb 188 + className={`flex w-4 h-4 rounded-full border-2 border-white cursor-pointer 189 + ${selectedPreset === "custom" ? "bg-[#595959] shadow-[0_0_0_1px_#8C8C8C,inset_0_0_0_1px_#8C8C8C]" : "bg-[#C3C3C3]"} 190 + `} 191 + aria-label="Max Page Width" 192 + /> 193 + </Slider.Root> 194 + </div> 195 + </Radio> 196 + </label> 197 + </div> 198 + )} 199 + </div> 200 + ); 201 + };
+2 -2
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 1 1 import { pickers } from "../ThemeSetter"; 2 - import { PageTextPicker } from "../Pickers/PageThemePickers"; 2 + import { TextPickers } from "../Pickers/PageThemePickers"; 3 3 import { Color } from "react-aria-components"; 4 4 5 5 export const PagePickers = (props: { ··· 20 20 : "transparent", 21 21 }} 22 22 > 23 - <PageTextPicker 23 + <TextPickers 24 24 value={props.primary} 25 25 setValue={props.setPrimary} 26 26 openPicker={props.openPicker}
+41 -8
components/ThemeManager/PubThemeSetter.tsx
··· 15 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 17 import { Separator } from "components/Layout"; 18 - import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 18 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; 19 19 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 + import { useToaster } from "components/Toast"; 21 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 + import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 20 23 21 24 export type ImageState = { 22 25 src: string; ··· 54 57 } 55 58 : null, 56 59 ); 57 - 60 + let [pageWidth, setPageWidth] = useState<number>( 61 + record?.theme?.pageWidth || 624, 62 + ); 58 63 let pubBGImage = image?.src || null; 59 64 let leafletBGRepeat = image?.repeat || null; 65 + let toaster = useToaster(); 60 66 61 67 return ( 62 - <BaseThemeProvider local {...localPubTheme}> 68 + <BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}> 63 69 <form 64 70 onSubmit={async (e) => { 65 71 e.preventDefault(); ··· 75 81 : ColorToRGB(localPubTheme.bgLeaflet), 76 82 backgroundRepeat: image?.repeat, 77 83 backgroundImage: image ? image.file : null, 84 + pageWidth: pageWidth, 78 85 primary: ColorToRGB(localPubTheme.primary), 79 86 accentBackground: ColorToRGB(localPubTheme.accent1), 80 87 accentText: ColorToRGB(localPubTheme.accent2), 81 88 }, 82 89 }); 90 + 91 + if (!result.success) { 92 + props.setLoading(false); 93 + if (result.error && isOAuthSessionError(result.error)) { 94 + toaster({ 95 + content: <OAuthErrorMessage error={result.error} />, 96 + type: "error", 97 + }); 98 + } else { 99 + toaster({ 100 + content: "Failed to update theme", 101 + type: "error", 102 + }); 103 + } 104 + return; 105 + } 106 + 83 107 mutate((pub) => { 84 - if (result?.publication && pub?.publication) 108 + if (result.publication && pub?.publication) 85 109 return { 86 110 ...pub, 87 111 publication: { ...pub.publication, ...result.publication }, ··· 96 120 setLoadingAction={props.setLoading} 97 121 backToMenuAction={props.backToMenu} 98 122 state={"theme"} 99 - /> 123 + > 124 + Theme and Layout 125 + </PubSettingsHeader> 100 126 </form> 101 127 102 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 "> 103 - <div className="themeBGLeaflet flex"> 128 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 mt-2 "> 129 + <PubPageWidthSetter 130 + pageWidth={pageWidth} 131 + setPageWidth={setPageWidth} 132 + thisPicker="page-width" 133 + openPicker={openPicker} 134 + setOpenPicker={setOpenPicker} 135 + /> 136 + <div className="themeBGLeaflet flex flex-col"> 104 137 <div 105 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 138 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 106 139 > 107 140 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 108 141 <BackgroundPicker
+20 -7
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 - import { BaseThemeProvider } from "./ThemeProvider"; 7 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 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 }} ··· 100 102 pub_creator: string; 101 103 isStandalone?: boolean; 102 104 }) { 103 - let colors = usePubTheme(props.theme, props.isStandalone); 105 + let theme = usePubTheme(props.theme, props.isStandalone); 106 + let cardBorderHidden = !theme.showPageBackground; 107 + let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref; 108 + 104 109 return ( 105 - <BaseThemeProvider local={props.local} {...colors}> 106 - {props.children} 107 - </BaseThemeProvider> 110 + <CardBorderHiddenContext.Provider value={cardBorderHidden}> 111 + <BaseThemeProvider 112 + local={props.local} 113 + {...theme} 114 + hasBackgroundImage={hasBackgroundImage} 115 + > 116 + {props.children} 117 + </BaseThemeProvider> 118 + </CardBorderHiddenContext.Provider> 108 119 ); 109 120 } 110 121 ··· 122 133 bgPage = bgLeaflet; 123 134 } 124 135 let showPageBackground = theme?.showPageBackground; 136 + let pageWidth = theme?.pageWidth; 125 137 126 138 let primary = useColor(theme, "primary"); 127 139 ··· 142 154 highlight2, 143 155 highlight3, 144 156 showPageBackground, 157 + pageWidth, 145 158 }; 146 159 }; 147 160
+52 -63
components/ThemeManager/ThemeProvider.tsx
··· 1 1 "use client"; 2 2 3 - import { 4 - createContext, 5 - CSSProperties, 6 - useContext, 7 - useEffect, 8 - useMemo, 9 - useState, 10 - } from "react"; 3 + import { createContext, CSSProperties, useContext, useEffect } from "react"; 4 + 5 + // Context for cardBorderHidden 6 + export const CardBorderHiddenContext = createContext<boolean>(false); 7 + 8 + export function useCardBorderHiddenContext() { 9 + return useContext(CardBorderHiddenContext); 10 + } 11 11 import { 12 12 colorToString, 13 13 useColorAttribute, 14 14 useColorAttributeNullable, 15 15 } from "./useColorAttribute"; 16 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 17 19 18 import { useEntity } from "src/replicache"; 20 19 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 22 PublicationThemeProvider, 24 23 } from "./PublicationThemeProvider"; 25 24 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 - }; 25 + import { getColorContrast } from "./themeUtils"; 54 26 55 27 // define a function to set an Aria Color to a CSS Variable in RGB 56 28 function setCSSVariableToColor( ··· 88 60 }) { 89 61 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 90 62 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 91 - let showPageBackground = !useEntity( 63 + let cardBorderHiddenValue = useEntity( 92 64 props.entityID, 93 65 "theme/card-border-hidden", 94 66 )?.data.value; 67 + let showPageBackground = !cardBorderHiddenValue; 68 + let backgroundImage = useEntity(props.entityID, "theme/background-image"); 69 + let hasBackgroundImage = !!backgroundImage; 95 70 let primary = useColorAttribute(props.entityID, "theme/primary"); 96 71 97 72 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 101 76 let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 102 77 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 103 78 79 + let pageWidth = useEntity(props.entityID, "theme/page-width"); 80 + 104 81 return ( 105 - <BaseThemeProvider 106 - local={props.local} 107 - bgLeaflet={bgLeaflet} 108 - bgPage={bgPage} 109 - primary={primary} 110 - highlight2={highlight2} 111 - highlight3={highlight3} 112 - highlight1={highlight1?.data.value} 113 - accent1={accent1} 114 - accent2={accent2} 115 - showPageBackground={showPageBackground} 116 - > 117 - {props.children} 118 - </BaseThemeProvider> 82 + <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 83 + <BaseThemeProvider 84 + local={props.local} 85 + bgLeaflet={bgLeaflet} 86 + bgPage={bgPage} 87 + primary={primary} 88 + highlight2={highlight2} 89 + highlight3={highlight3} 90 + highlight1={highlight1?.data.value} 91 + accent1={accent1} 92 + accent2={accent2} 93 + showPageBackground={showPageBackground} 94 + pageWidth={pageWidth?.data.value} 95 + hasBackgroundImage={hasBackgroundImage} 96 + > 97 + {props.children} 98 + </BaseThemeProvider> 99 + </CardBorderHiddenContext.Provider> 119 100 ); 120 101 } 121 102 ··· 123 104 export const BaseThemeProvider = ({ 124 105 local, 125 106 bgLeaflet, 126 - bgPage, 107 + bgPage: bgPageProp, 127 108 primary, 128 109 accent1, 129 110 accent2, ··· 131 112 highlight2, 132 113 highlight3, 133 114 showPageBackground, 115 + pageWidth, 116 + hasBackgroundImage, 134 117 children, 135 118 }: { 136 119 local?: boolean; 137 120 showPageBackground?: boolean; 121 + hasBackgroundImage?: boolean; 138 122 bgLeaflet: AriaColor; 139 123 bgPage: AriaColor; 140 124 primary: AriaColor; ··· 143 127 highlight1?: string; 144 128 highlight2: AriaColor; 145 129 highlight3: AriaColor; 130 + pageWidth?: number; 146 131 children: React.ReactNode; 147 132 }) => { 133 + // When showPageBackground is false and there's no background image, 134 + // pageBg should inherit from leafletBg 135 + const bgPage = 136 + !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 148 137 // set accent contrast to the accent color that has the highest contrast with the page background 149 138 let accentContrast; 150 139 ··· 220 209 el?.style.setProperty( 221 210 "--accent-1-is-contrast", 222 211 accentContrast === accent1 ? "1" : "0", 212 + ); 213 + 214 + // Set page width CSS variable 215 + el?.style.setProperty( 216 + "--page-width-setting", 217 + (pageWidth || 624).toString(), 223 218 ); 224 219 }, [ 225 220 local, ··· 232 227 accent1, 233 228 accent2, 234 229 accentContrast, 230 + pageWidth, 235 231 ]); 236 232 return ( 237 233 <div ··· 251 247 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 252 248 "--highlight-2": colorToString(highlight2, "rgb"), 253 249 "--highlight-3": colorToString(highlight3, "rgb"), 250 + "--page-width-setting": pageWidth || 624, 251 + "--page-width-unitless": pageWidth || 624, 252 + "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 254 253 } as CSSProperties 255 254 } 256 255 > ··· 367 366 </div> 368 367 ); 369 368 }; 370 - 371 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 372 - export function getColorContrast(color1: string, color2: string) { 373 - ColorSpace.register(sRGB); 374 - 375 - let parsedColor1 = parse(`rgb(${color1})`); 376 - let parsedColor2 = parse(`rgb(${color2})`); 377 - 378 - return contrastLstar(parsedColor1, parsedColor2); 379 - }
+21 -35
components/ThemeManager/ThemeSetter.tsx
··· 1 1 "use client"; 2 2 import { Popover } from "components/Popover"; 3 - import { theme } from "../../tailwind.config"; 4 3 5 4 import { Color } from "react-aria-components"; 6 5 7 - import { LeafletBGPicker } from "./Pickers/LeafletBGPicker"; 8 6 import { 9 - PageBackgroundPicker, 10 - PageBorderHider, 7 + LeafletBackgroundPicker, 11 8 PageThemePickers, 12 9 } from "./Pickers/PageThemePickers"; 10 + import { PageWidthSetter } from "./Pickers/PageWidthSetter"; 13 11 import { useMemo, useState } from "react"; 14 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 15 13 import { Replicache } from "replicache"; ··· 35 33 | "highlight-1" 36 34 | "highlight-2" 37 35 | "highlight-3" 38 - | "page-background-image"; 36 + | "page-background-image" 37 + | "page-width"; 39 38 40 39 export function setColorAttribute( 41 40 rep: Replicache<ReplicacheMutators> | null, ··· 75 74 return ( 76 75 <> 77 76 <Popover 78 - className="w-80 bg-white" 77 + className="w-80 bg-white py-3!" 79 78 arrowFill="#FFFFFF" 80 79 asChild 81 80 side={isMobile ? "top" : "right"} ··· 114 113 if (pub?.publications) return null; 115 114 return ( 116 115 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 116 + {!props.home && ( 117 + <PageWidthSetter 118 + entityID={props.entityID} 119 + thisPicker={"page-width"} 120 + openPicker={openPicker} 121 + setOpenPicker={setOpenPicker} 122 + closePicker={() => setOpenPicker("null")} 123 + /> 124 + )} 117 125 <div className="themeBGLeaflet flex"> 118 126 <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 127 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 120 - <LeafletBGPicker 121 - entityID={props.entityID} 122 - thisPicker={"leaflet"} 123 - openPicker={openPicker} 124 - setOpenPicker={setOpenPicker} 125 - closePicker={() => setOpenPicker("null")} 126 - setValue={set("theme/page-background")} 127 - /> 128 - <PageBackgroundPicker 129 - entityID={props.entityID} 130 - setValue={set("theme/card-background")} 131 - openPicker={openPicker} 132 - setOpenPicker={setOpenPicker} 133 - home={props.home} 134 - /> 135 - <hr className=" border-[#CCCCCC]" /> 136 - <PageBorderHider 128 + <LeafletBackgroundPicker 137 129 entityID={props.entityID} 138 130 openPicker={openPicker} 139 131 setOpenPicker={setOpenPicker} ··· 173 165 setOpenPicker={(pickers) => setOpenPicker(pickers)} 174 166 /> 175 167 <SectionArrow 176 - fill={theme.colors["accent-2"]} 177 - stroke={theme.colors["accent-1"]} 168 + fill="rgb(var(--accent-2))" 169 + stroke="rgb(var(--accent-1))" 178 170 className="ml-2" 179 171 /> 180 172 </div> ··· 209 201 return ( 210 202 <div className="flex gap-2 items-start mt-0.5"> 211 203 <Toggle 212 - toggleOn={!!checked?.data.value} 213 - setToggleOn={() => { 204 + toggle={!!checked?.data.value} 205 + onToggle={() => { 214 206 handleToggle(); 215 207 }} 216 208 disabledColor1="#8C8C8C" 217 209 disabledColor2="#DBDBDB" 218 - /> 219 - <button 220 - className="flex gap-2 items-center -mt-0.5" 221 - onClick={() => { 222 - handleToggle(); 223 - }} 224 210 > 225 - <div className="flex flex-col gap-0 items-start"> 211 + <div className="flex flex-col gap-0 items-start "> 226 212 <div className="font-bold">Show Leaflet Watermark</div> 227 213 <div className="text-sm text-[#969696]">Help us spread the word!</div> 228 214 </div> 229 - </button> 215 + </Toggle> 230 216 </div> 231 217 ); 232 218 }
+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,
+32 -20
components/Toggle.tsx
··· 1 1 import { theme } from "tailwind.config"; 2 2 3 3 export const Toggle = (props: { 4 - toggleOn: boolean; 5 - setToggleOn: (s: boolean) => void; 4 + toggle: boolean; 5 + onToggle: () => void; 6 6 disabledColor1?: string; 7 7 disabledColor2?: string; 8 + children: React.ReactNode; 8 9 }) => { 9 10 return ( 10 11 <button 11 - className="toggle selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border" 12 - style={{ 13 - border: props.toggleOn 14 - ? "1px solid " + theme.colors["accent-2"] 15 - : "1px solid " + props.disabledColor2 || theme.colors["border-light"], 16 - justifyContent: props.toggleOn ? "flex-end" : "flex-start", 17 - background: props.toggleOn 18 - ? theme.colors["accent-1"] 19 - : props.disabledColor1 || theme.colors["tertiary"], 12 + type="button" 13 + className="toggle flex gap-2 items-start justify-start text-left" 14 + onClick={() => { 15 + props.onToggle(); 20 16 }} 21 - onClick={() => props.setToggleOn(!props.toggleOn)} 22 17 > 23 - <div 24 - className="h-[14px] w-[10px] m-0.5 rounded-[2px]" 25 - style={{ 26 - background: props.toggleOn 27 - ? theme.colors["accent-2"] 28 - : props.disabledColor2 || theme.colors["border-light"], 29 - }} 30 - /> 18 + <div className="h-6 flex place-items-center"> 19 + <div 20 + className="selected-outline transparent-outline flex items-center h-[20px] w-6 rounded-md border-border" 21 + style={{ 22 + border: props.toggle 23 + ? "1px solid " + theme.colors["accent-2"] 24 + : "1px solid " + props.disabledColor2 || 25 + theme.colors["border-light"], 26 + justifyContent: props.toggle ? "flex-end" : "flex-start", 27 + background: props.toggle 28 + ? theme.colors["accent-1"] 29 + : props.disabledColor1 || theme.colors["tertiary"], 30 + }} 31 + > 32 + <div 33 + className="h-[14px] w-[10px] m-0.5 rounded-[2px]" 34 + style={{ 35 + background: props.toggle 36 + ? theme.colors["accent-2"] 37 + : props.disabledColor2 || theme.colors["border-light"], 38 + }} 39 + /> 40 + </div> 41 + </div> 42 + {props.children} 31 43 </button> 32 44 ); 33 45 };
+7 -15
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 - import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 8 + import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } 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: ( ··· 44 44 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 45 45 <ImageFullBleedButton /> 46 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 47 + <ImageCoverButton /> 47 48 {focusedEntityType?.data.value !== "canvas" && ( 48 49 <Separator classname="h-6" /> 49 50 )} ··· 66 67 67 68 const MoveBlockButtons = () => { 68 69 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 70 return ( 81 71 <> 82 72 <ToolbarButton 83 73 hiddenOnCanvas 84 74 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 75 + if (!rep) return; 76 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 86 77 if (sortedBlocks.length > 1) return; 87 78 let block = sortedBlocks[0]; 88 79 let previousBlock = ··· 139 130 <ToolbarButton 140 131 hiddenOnCanvas 141 132 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 133 + if (!rep) return; 134 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 143 135 if (sortedBlocks.length > 1) return; 144 136 let block = sortedBlocks[0]; 145 137 let nextBlock = siblings
+37
components/Toolbar/ImageToolbar.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { Props } from "components/Icons/Props"; 6 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { useSubscribe } from "src/replicache/useSubscribe"; 9 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 7 10 8 11 export const ImageFullBleedButton = (props: {}) => { 9 12 let { rep } = useReplicache(); ··· 76 79 ) : ( 77 80 <ImageRemoveAltSmall /> 78 81 )} 82 + </ToolbarButton> 83 + ); 84 + }; 85 + 86 + export const ImageCoverButton = () => { 87 + let { rep } = useReplicache(); 88 + let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 89 + let hasSrc = useEntity(focusedBlock, "block/image")?.data; 90 + let { data: pubData } = useLeafletPublicationData(); 91 + let coverImage = useSubscribe(rep, (tx) => 92 + tx.get<string | null>("publication_cover_image"), 93 + ); 94 + 95 + // Only show if in a publication and has an image 96 + if (!pubData?.publications || !hasSrc) return null; 97 + 98 + let isCoverImage = coverImage === focusedBlock; 99 + 100 + return ( 101 + <ToolbarButton 102 + active={isCoverImage} 103 + onClick={async (e) => { 104 + e.preventDefault(); 105 + if (rep && focusedBlock) { 106 + await rep.mutate.updatePublicationDraft({ 107 + cover_image: isCoverImage ? null : focusedBlock, 108 + }); 109 + } 110 + }} 111 + tooltipContent={ 112 + <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 113 + } 114 + > 115 + <ImageCoverImage /> 79 116 </ToolbarButton> 80 117 ); 81 118 };
+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/Tooltip.tsx
··· 26 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 27 } 28 28 > 29 - <RadixTooltip.Root> 29 + <RadixTooltip.Root onOpenChange={props.onOpenChange}> 30 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 31 {props.trigger} 32 32 </RadixTooltip.Trigger>
+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";
+49
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 + }, 1450 + coverImage: { 1451 + type: 'blob', 1452 + accept: ['image/png', 'image/jpeg', 'image/webp'], 1453 + maxSize: 1000000, 1454 + }, 1443 1455 pages: { 1444 1456 type: 'array', 1445 1457 items: { ··· 1794 1806 type: 'boolean', 1795 1807 default: true, 1796 1808 }, 1809 + showMentions: { 1810 + type: 'boolean', 1811 + default: true, 1812 + }, 1813 + showPrevNext: { 1814 + type: 'boolean', 1815 + default: true, 1816 + }, 1797 1817 }, 1798 1818 }, 1799 1819 theme: { ··· 1810 1830 type: 'ref', 1811 1831 ref: 'lex:pub.leaflet.theme.backgroundImage', 1812 1832 }, 1833 + pageWidth: { 1834 + type: 'integer', 1835 + minimum: 0, 1836 + maximum: 1600, 1837 + }, 1813 1838 primary: { 1814 1839 type: 'union', 1815 1840 refs: [ ··· 1865 1890 type: 'union', 1866 1891 refs: [ 1867 1892 'lex:pub.leaflet.richtext.facet#link', 1893 + 'lex:pub.leaflet.richtext.facet#didMention', 1894 + 'lex:pub.leaflet.richtext.facet#atMention', 1868 1895 'lex:pub.leaflet.richtext.facet#code', 1869 1896 'lex:pub.leaflet.richtext.facet#highlight', 1870 1897 'lex:pub.leaflet.richtext.facet#underline', ··· 1901 1928 properties: { 1902 1929 uri: { 1903 1930 type: 'string', 1931 + }, 1932 + }, 1933 + }, 1934 + didMention: { 1935 + type: 'object', 1936 + description: 'Facet feature for mentioning a did.', 1937 + required: ['did'], 1938 + properties: { 1939 + did: { 1940 + type: 'string', 1941 + format: 'did', 1942 + }, 1943 + }, 1944 + }, 1945 + atMention: { 1946 + type: 'object', 1947 + description: 'Facet feature for mentioning an AT URI.', 1948 + required: ['atURI'], 1949 + properties: { 1950 + atURI: { 1951 + type: 'string', 1952 + format: 'uri', 1904 1953 }, 1905 1954 }, 1906 1955 },
+2
lexicons/api/types/pub/leaflet/document.ts
··· 23 23 publication?: string 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 + tags?: string[] 27 + coverImage?: BlobRef 26 28 pages: ( 27 29 | $Typed<PubLeafletPagesLinearDocument.Main> 28 30 | $Typed<PubLeafletPagesCanvas.Main>
+3
lexicons/api/types/pub/leaflet/publication.ts
··· 37 37 $type?: 'pub.leaflet.publication#preferences' 38 38 showInDiscover: boolean 39 39 showComments: boolean 40 + showMentions: boolean 41 + showPrevNext: boolean 40 42 } 41 43 42 44 const hashPreferences = 'preferences' ··· 56 58 | $Typed<PubLeafletThemeColor.Rgb> 57 59 | { $type: string } 58 60 backgroundImage?: PubLeafletThemeBackgroundImage.Main 61 + pageWidth?: number 59 62 primary?: 60 63 | $Typed<PubLeafletThemeColor.Rgba> 61 64 | $Typed<PubLeafletThemeColor.Rgb>
+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. */
+2
lexicons/build.ts
··· 9 9 import * as path from "path"; 10 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 11 import { PubLeafletComment } from "./src/comment"; 12 + import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 12 13 13 14 const outdir = path.join("lexicons", "pub", "leaflet"); 14 15 ··· 21 22 PubLeafletDocument, 22 23 PubLeafletComment, 23 24 PubLeafletRichTextFacet, 25 + PubLeafletAuthFullPermissions, 24 26 PageLexicons.PubLeafletPagesLinearDocument, 25 27 PageLexicons.PubLeafletPagesCanvasDocument, 26 28 ...ThemeLexicons,
+44
lexicons/fix-extensions.ts
··· 1 + import * as fs from "fs"; 2 + import * as path from "path"; 3 + 4 + /** 5 + * Recursively processes all files in a directory and removes .js extensions from imports 6 + */ 7 + function fixExtensionsInDirectory(dir: string): void { 8 + const entries = fs.readdirSync(dir, { withFileTypes: true }); 9 + 10 + for (const entry of entries) { 11 + const fullPath = path.join(dir, entry.name); 12 + 13 + if (entry.isDirectory()) { 14 + fixExtensionsInDirectory(fullPath); 15 + } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { 16 + fixExtensionsInFile(fullPath); 17 + } 18 + } 19 + } 20 + 21 + /** 22 + * Removes .js extensions from import/export statements in a file 23 + */ 24 + function fixExtensionsInFile(filePath: string): void { 25 + const content = fs.readFileSync(filePath, "utf-8"); 26 + const fixedContent = content.replace(/\.js'/g, "'"); 27 + 28 + if (content !== fixedContent) { 29 + fs.writeFileSync(filePath, fixedContent, "utf-8"); 30 + console.log(`Fixed: ${filePath}`); 31 + } 32 + } 33 + 34 + // Get the directory to process from command line arguments 35 + const targetDir = process.argv[2] || "./lexicons/api"; 36 + 37 + if (!fs.existsSync(targetDir)) { 38 + console.error(`Directory not found: ${targetDir}`); 39 + process.exit(1); 40 + } 41 + 42 + console.log(`Fixing extensions in: ${targetDir}`); 43 + fixExtensionsInDirectory(targetDir); 44 + console.log("Done!");
+30
lexicons/pub/leaflet/authFullPermissions.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.authFullPermissions", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Full Leaflet Permissions", 8 + "detail": "Manage creating and updating leaflet documents and publications and all interactions on them.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "repo", 13 + "action": [ 14 + "create", 15 + "update", 16 + "delete" 17 + ], 18 + "collection": [ 19 + "pub.leaflet.document", 20 + "pub.leaflet.publication", 21 + "pub.leaflet.comment", 22 + "pub.leaflet.poll.definition", 23 + "pub.leaflet.poll.vote", 24 + "pub.leaflet.graph.subscription" 25 + ] 26 + } 27 + ] 28 + } 29 + } 30 + }
+16
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 + }, 56 + "coverImage": { 57 + "type": "blob", 58 + "accept": [ 59 + "image/png", 60 + "image/jpeg", 61 + "image/webp" 62 + ], 63 + "maxSize": 1000000 64 + }, 49 65 "pages": { 50 66 "type": "array", 51 67 "items": {
+13
lexicons/pub/leaflet/publication.json
··· 51 51 "showComments": { 52 52 "type": "boolean", 53 53 "default": true 54 + }, 55 + "showMentions": { 56 + "type": "boolean", 57 + "default": true 58 + }, 59 + "showPrevNext": { 60 + "type": "boolean", 61 + "default": true 54 62 } 55 63 } 56 64 }, ··· 67 75 "backgroundImage": { 68 76 "type": "ref", 69 77 "ref": "pub.leaflet.theme.backgroundImage" 78 + }, 79 + "pageWidth": { 80 + "type": "integer", 81 + "minimum": 0, 82 + "maximum": 1600 70 83 }, 71 84 "primary": { 72 85 "type": "union",
+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 },
+36
lexicons/src/authFullPermissions.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletDocument } from "./document"; 3 + import { 4 + PubLeafletPublication, 5 + PubLeafletPublicationSubscription, 6 + } from "./publication"; 7 + import { PubLeafletComment } from "./comment"; 8 + import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; 9 + 10 + export const PubLeafletAuthFullPermissions: LexiconDoc = { 11 + lexicon: 1, 12 + id: "pub.leaflet.authFullPermissions", 13 + defs: { 14 + main: { 15 + type: "permission-set", 16 + title: "Full Leaflet Permissions", 17 + detail: 18 + "Manage creating and updating leaflet documents and publications and all interactions on them.", 19 + permissions: [ 20 + { 21 + type: "permission", 22 + resource: "repo", 23 + action: ["create", "update", "delete"], 24 + collection: [ 25 + PubLeafletDocument.id, 26 + PubLeafletPublication.id, 27 + PubLeafletComment.id, 28 + PubLeafletPollDefinition.id, 29 + PubLeafletPollVote.id, 30 + PubLeafletPublicationSubscription.id, 31 + ], 32 + }, 33 + ], 34 + }, 35 + }, 36 + };
+6
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 } }, 27 + coverImage: { 28 + type: "blob", 29 + accept: ["image/png", "image/jpeg", "image/webp"], 30 + maxSize: 1000000, 31 + }, 26 32 pages: { 27 33 type: "array", 28 34 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.",
+7
lexicons/src/publication.ts
··· 27 27 properties: { 28 28 showInDiscover: { type: "boolean", default: true }, 29 29 showComments: { type: "boolean", default: true }, 30 + showMentions: { type: "boolean", default: true }, 31 + showPrevNext: { type: "boolean", default: false }, 30 32 }, 31 33 }, 32 34 theme: { ··· 36 38 backgroundImage: { 37 39 type: "ref", 38 40 ref: PubLeafletThemeBackgroundImage.id, 41 + }, 42 + pageWidth: { 43 + type: "integer", 44 + minimum: 0, 45 + maximum: 1600, 39 46 }, 40 47 primary: ColorUnion, 41 48 pageBackground: ColorUnion,
+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": {
+5 -4
package.json
··· 7 7 "dev": "TZ=UTC next dev --turbo", 8 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", 10 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 11 11 "wrangler-dev": "wrangler dev", 12 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 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",
+27
src/atproto-oauth.ts
··· 3 3 NodeSavedSession, 4 4 NodeSavedState, 5 5 RuntimeLock, 6 + OAuthSession, 6 7 } from "@atproto/oauth-client-node"; 7 8 import { JoseKey } from "@atproto/jwk-jose"; 8 9 import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; ··· 10 11 11 12 import Client from "ioredis"; 12 13 import Redlock from "redlock"; 14 + import { Result, Ok, Err } from "./result"; 13 15 export async function createOauthClient() { 14 16 let keyset = 15 17 process.env.NODE_ENV === "production" ··· 90 92 .eq("key", key); 91 93 }, 92 94 }; 95 + 96 + export type OAuthSessionError = { 97 + type: "oauth_session_expired"; 98 + message: string; 99 + did: string; 100 + }; 101 + 102 + export async function restoreOAuthSession( 103 + did: string 104 + ): Promise<Result<OAuthSession, OAuthSessionError>> { 105 + try { 106 + const oauthClient = await createOauthClient(); 107 + const session = await oauthClient.restore(did); 108 + return Ok(session); 109 + } catch (error) { 110 + return Err({ 111 + type: "oauth_session_expired", 112 + message: 113 + error instanceof Error 114 + ? error.message 115 + : "OAuth session expired or invalid", 116 + did, 117 + }); 118 + } 119 + }
+1 -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 33 34 34 // Apply timezone if available
+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) {
+4
src/replicache/attributes.ts
··· 191 191 type: "boolean", 192 192 cardinality: "one", 193 193 }, 194 + "theme/page-width": { 195 + type: "number", 196 + cardinality: "one", 197 + }, 194 198 "theme/page-background": { 195 199 type: "color", 196 200 cardinality: "one",
+64 -9
src/replicache/mutations.ts
··· 319 319 await supabase.storage 320 320 .from("minilink-user-assets") 321 321 .remove([paths[paths.length - 1]]); 322 + 323 + // Clear cover image if this block is the cover image 324 + // First try leaflets_in_publications 325 + const { data: pubResult } = await supabase 326 + .from("leaflets_in_publications") 327 + .update({ cover_image: null }) 328 + .eq("leaflet", ctx.permission_token_id) 329 + .eq("cover_image", block.blockEntity) 330 + .select("leaflet"); 331 + 332 + // If no rows updated, try leaflets_to_documents 333 + if (!pubResult || pubResult.length === 0) { 334 + await supabase 335 + .from("leaflets_to_documents") 336 + .update({ cover_image: null }) 337 + .eq("leaflet", ctx.permission_token_id) 338 + .eq("cover_image", block.blockEntity); 339 + } 322 340 } 323 341 }); 324 - await ctx.runOnClient(async () => { 342 + await ctx.runOnClient(async ({ tx }) => { 325 343 let cache = await caches.open("minilink-user-assets"); 326 344 if (image) { 327 345 await cache.delete(image.data.src + "?local"); 346 + 347 + // Clear cover image in client state if this block was the cover image 348 + let currentCoverImage = await tx.get("publication_cover_image"); 349 + if (currentCoverImage === block.blockEntity) { 350 + await tx.set("publication_cover_image", null); 351 + } 328 352 } 329 353 }); 330 354 await ctx.deleteEntity(block.blockEntity); ··· 609 633 }; 610 634 611 635 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 636 + title?: string; 637 + description?: string; 638 + tags?: string[]; 639 + cover_image?: string | null; 614 640 }> = async (args, ctx) => { 615 641 await ctx.runOnServer(async (serverCtx) => { 616 642 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); 643 + const updates: { 644 + description?: string; 645 + title?: string; 646 + tags?: string[]; 647 + cover_image?: string | null; 648 + } = {}; 649 + if (args.description !== undefined) updates.description = args.description; 650 + if (args.title !== undefined) updates.title = args.title; 651 + if (args.tags !== undefined) updates.tags = args.tags; 652 + if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 653 + 654 + if (Object.keys(updates).length > 0) { 655 + // First try to update leaflets_in_publications (for publications) 656 + const { data: pubResult } = await serverCtx.supabase 657 + .from("leaflets_in_publications") 658 + .update(updates) 659 + .eq("leaflet", ctx.permission_token_id) 660 + .select("leaflet"); 661 + 662 + // If no rows were updated in leaflets_in_publications, 663 + // try leaflets_to_documents (for standalone documents) 664 + if (!pubResult || pubResult.length === 0) { 665 + await serverCtx.supabase 666 + .from("leaflets_to_documents") 667 + .update(updates) 668 + .eq("leaflet", ctx.permission_token_id); 669 + } 670 + } 621 671 }); 622 672 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 673 + if (args.title !== undefined) 674 + await tx.set("publication_title", args.title); 675 + if (args.description !== undefined) 676 + await tx.set("publication_description", args.description); 677 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 678 + if (args.cover_image !== undefined) 679 + await tx.set("publication_cover_image", args.cover_image); 625 680 }); 626 681 }; 627 682
+8
src/result.ts
··· 1 + // Result type - a discriminated union for handling success/error cases 2 + export type Result<T, E> = 3 + | { ok: true; value: T } 4 + | { ok: false; error: E }; 5 + 6 + // Constructors 7 + export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value }); 8 + export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
+28
src/useThreadState.ts
··· 1 + import { create } from "zustand"; 2 + import { combine } from "zustand/middleware"; 3 + 4 + export const useThreadState = create( 5 + combine( 6 + { 7 + // Set of collapsed thread URIs 8 + collapsedThreads: new Set<string>(), 9 + }, 10 + (set) => ({ 11 + toggleCollapsed: (uri: string) => { 12 + set((state) => { 13 + const newCollapsed = new Set(state.collapsedThreads); 14 + if (newCollapsed.has(uri)) { 15 + newCollapsed.delete(uri); 16 + } else { 17 + newCollapsed.add(uri); 18 + } 19 + return { collapsedThreads: newCollapsed }; 20 + }); 21 + }, 22 + isCollapsed: (uri: string) => { 23 + // This is a selector helper, but we'll use the state directly 24 + return false; 25 + }, 26 + }), 27 + ), 28 + );
+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 + }
+24 -5
supabase/database.types.ts
··· 556 556 atp_did?: string | null 557 557 created_at?: string 558 558 email?: string | null 559 - home_page: string 559 + home_page?: string 560 560 id?: string 561 561 interface_state?: Json | null 562 562 } ··· 581 581 leaflets_in_publications: { 582 582 Row: { 583 583 archived: boolean | null 584 + cover_image: string | null 584 585 description: string 585 586 doc: string | null 586 587 leaflet: string ··· 589 590 } 590 591 Insert: { 591 592 archived?: boolean | null 593 + cover_image?: string | null 592 594 description?: string 593 595 doc?: string | null 594 596 leaflet: string ··· 597 599 } 598 600 Update: { 599 601 archived?: boolean | null 602 + cover_image?: string | null 600 603 description?: string 601 604 doc?: string | null 602 605 leaflet?: string ··· 629 632 } 630 633 leaflets_to_documents: { 631 634 Row: { 635 + cover_image: string | null 632 636 created_at: string 633 637 description: string 634 - document: string | null 638 + document: string 635 639 leaflet: string 636 640 title: string 637 641 } 638 642 Insert: { 643 + cover_image?: string | null 639 644 created_at?: string 640 645 description?: string 641 - document?: string | null 646 + document: string 642 647 leaflet: string 643 648 title?: string 644 649 } 645 650 Update: { 651 + cover_image?: string | null 646 652 created_at?: string 647 653 description?: string 648 - document?: string | null 654 + document?: string 649 655 leaflet?: string 650 656 title?: string 651 657 } ··· 660 666 { 661 667 foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 668 columns: ["leaflet"] 663 - isOneToOne: true 669 + isOneToOne: false 664 670 referencedRelation: "permission_tokens" 665 671 referencedColumns: ["id"] 666 672 }, ··· 1112 1118 [_ in never]: never 1113 1119 } 1114 1120 Functions: { 1121 + create_identity_homepage: { 1122 + Args: Record<PropertyKey, never> 1123 + Returns: string 1124 + } 1115 1125 get_facts: { 1116 1126 Args: { 1117 1127 root: string ··· 1157 1167 client_group_id: string 1158 1168 } 1159 1169 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1170 + } 1171 + search_tags: { 1172 + Args: { 1173 + search_query: string 1174 + } 1175 + Returns: { 1176 + name: string 1177 + document_count: number 1178 + }[] 1160 1179 } 1161 1180 } 1162 1181 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 + ;
+2
supabase/migrations/20251223000000_add_cover_image_column.sql
··· 1 + alter table "public"."leaflets_in_publications" add column "cover_image" text; 2 + alter table "public"."leaflets_to_documents" add column "cover_image" text;
+34
supabase/migrations/20260106183631_add_homepage_default_to_identities.sql
··· 1 + -- Function to create homepage infrastructure for new identities 2 + -- Replicates the logic from createIdentity TypeScript function 3 + -- Returns the permission token ID to be used as home_page 4 + CREATE OR REPLACE FUNCTION create_identity_homepage() 5 + RETURNS uuid AS $$ 6 + DECLARE 7 + new_entity_set_id uuid; 8 + new_entity_id uuid; 9 + new_permission_token_id uuid; 10 + BEGIN 11 + -- Create a new entity set 12 + INSERT INTO entity_sets DEFAULT VALUES 13 + RETURNING id INTO new_entity_set_id; 14 + 15 + -- Create a root entity and add it to that entity set 16 + new_entity_id := gen_random_uuid(); 17 + INSERT INTO entities (id, set) 18 + VALUES (new_entity_id, new_entity_set_id); 19 + 20 + -- Create a new permission token 21 + INSERT INTO permission_tokens (root_entity) 22 + VALUES (new_entity_id) 23 + RETURNING id INTO new_permission_token_id; 24 + 25 + -- Give the token full permissions on that entity set 26 + INSERT INTO permission_token_rights (token, entity_set, read, write, create_token, change_entity_set) 27 + VALUES (new_permission_token_id, new_entity_set_id, true, true, true, true); 28 + 29 + RETURN new_permission_token_id; 30 + END; 31 + $$ LANGUAGE plpgsql; 32 + 33 + -- Set the function as the default value for home_page column 34 + ALTER TABLE identities ALTER COLUMN home_page SET DEFAULT create_identity_homepage();
+161
supabase/migrations/20260106190000_add_site_standard_tables.sql
··· 1 + -- site_standard_publications table (modeled off publications) 2 + create table "public"."site_standard_publications" ( 3 + "uri" text not null, 4 + "data" jsonb not null, 5 + "indexed_at" timestamp with time zone not null default now(), 6 + "identity_did" text not null 7 + ); 8 + alter table "public"."site_standard_publications" enable row level security; 9 + 10 + -- site_standard_documents table (modeled off documents) 11 + create table "public"."site_standard_documents" ( 12 + "uri" text not null, 13 + "data" jsonb not null, 14 + "indexed_at" timestamp with time zone not null default now(), 15 + "identity_did" text not null 16 + ); 17 + alter table "public"."site_standard_documents" enable row level security; 18 + 19 + -- site_standard_documents_in_publications relation table (modeled off documents_in_publications) 20 + create table "public"."site_standard_documents_in_publications" ( 21 + "publication" text not null, 22 + "document" text not null, 23 + "indexed_at" timestamp with time zone not null default now() 24 + ); 25 + alter table "public"."site_standard_documents_in_publications" enable row level security; 26 + 27 + -- Primary key indexes 28 + CREATE UNIQUE INDEX site_standard_publications_pkey ON public.site_standard_publications USING btree (uri); 29 + CREATE UNIQUE INDEX site_standard_documents_pkey ON public.site_standard_documents USING btree (uri); 30 + CREATE UNIQUE INDEX site_standard_documents_in_publications_pkey ON public.site_standard_documents_in_publications USING btree (publication, document); 31 + 32 + -- Add primary key constraints 33 + alter table "public"."site_standard_publications" add constraint "site_standard_publications_pkey" PRIMARY KEY using index "site_standard_publications_pkey"; 34 + alter table "public"."site_standard_documents" add constraint "site_standard_documents_pkey" PRIMARY KEY using index "site_standard_documents_pkey"; 35 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_pkey" PRIMARY KEY using index "site_standard_documents_in_publications_pkey"; 36 + 37 + -- Foreign key constraints for identity relations 38 + alter table "public"."site_standard_publications" add constraint "site_standard_publications_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 39 + alter table "public"."site_standard_publications" validate constraint "site_standard_publications_identity_did_fkey"; 40 + alter table "public"."site_standard_documents" add constraint "site_standard_documents_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 41 + alter table "public"."site_standard_documents" validate constraint "site_standard_documents_identity_did_fkey"; 42 + 43 + -- Foreign key constraints for relation table 44 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_document_fkey" FOREIGN KEY (document) REFERENCES site_standard_documents(uri) ON DELETE CASCADE not valid; 45 + alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_document_fkey"; 46 + alter table "public"."site_standard_documents_in_publications" add constraint "site_standard_documents_in_publications_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid; 47 + alter table "public"."site_standard_documents_in_publications" validate constraint "site_standard_documents_in_publications_publication_fkey"; 48 + 49 + -- Grants for site_standard_publications 50 + grant delete on table "public"."site_standard_publications" to "anon"; 51 + grant insert on table "public"."site_standard_publications" to "anon"; 52 + grant references on table "public"."site_standard_publications" to "anon"; 53 + grant select on table "public"."site_standard_publications" to "anon"; 54 + grant trigger on table "public"."site_standard_publications" to "anon"; 55 + grant truncate on table "public"."site_standard_publications" to "anon"; 56 + grant update on table "public"."site_standard_publications" to "anon"; 57 + grant delete on table "public"."site_standard_publications" to "authenticated"; 58 + grant insert on table "public"."site_standard_publications" to "authenticated"; 59 + grant references on table "public"."site_standard_publications" to "authenticated"; 60 + grant select on table "public"."site_standard_publications" to "authenticated"; 61 + grant trigger on table "public"."site_standard_publications" to "authenticated"; 62 + grant truncate on table "public"."site_standard_publications" to "authenticated"; 63 + grant update on table "public"."site_standard_publications" to "authenticated"; 64 + grant delete on table "public"."site_standard_publications" to "service_role"; 65 + grant insert on table "public"."site_standard_publications" to "service_role"; 66 + grant references on table "public"."site_standard_publications" to "service_role"; 67 + grant select on table "public"."site_standard_publications" to "service_role"; 68 + grant trigger on table "public"."site_standard_publications" to "service_role"; 69 + grant truncate on table "public"."site_standard_publications" to "service_role"; 70 + grant update on table "public"."site_standard_publications" to "service_role"; 71 + 72 + -- Grants for site_standard_documents 73 + grant delete on table "public"."site_standard_documents" to "anon"; 74 + grant insert on table "public"."site_standard_documents" to "anon"; 75 + grant references on table "public"."site_standard_documents" to "anon"; 76 + grant select on table "public"."site_standard_documents" to "anon"; 77 + grant trigger on table "public"."site_standard_documents" to "anon"; 78 + grant truncate on table "public"."site_standard_documents" to "anon"; 79 + grant update on table "public"."site_standard_documents" to "anon"; 80 + grant delete on table "public"."site_standard_documents" to "authenticated"; 81 + grant insert on table "public"."site_standard_documents" to "authenticated"; 82 + grant references on table "public"."site_standard_documents" to "authenticated"; 83 + grant select on table "public"."site_standard_documents" to "authenticated"; 84 + grant trigger on table "public"."site_standard_documents" to "authenticated"; 85 + grant truncate on table "public"."site_standard_documents" to "authenticated"; 86 + grant update on table "public"."site_standard_documents" to "authenticated"; 87 + grant delete on table "public"."site_standard_documents" to "service_role"; 88 + grant insert on table "public"."site_standard_documents" to "service_role"; 89 + grant references on table "public"."site_standard_documents" to "service_role"; 90 + grant select on table "public"."site_standard_documents" to "service_role"; 91 + grant trigger on table "public"."site_standard_documents" to "service_role"; 92 + grant truncate on table "public"."site_standard_documents" to "service_role"; 93 + grant update on table "public"."site_standard_documents" to "service_role"; 94 + 95 + -- Grants for site_standard_documents_in_publications 96 + grant delete on table "public"."site_standard_documents_in_publications" to "anon"; 97 + grant insert on table "public"."site_standard_documents_in_publications" to "anon"; 98 + grant references on table "public"."site_standard_documents_in_publications" to "anon"; 99 + grant select on table "public"."site_standard_documents_in_publications" to "anon"; 100 + grant trigger on table "public"."site_standard_documents_in_publications" to "anon"; 101 + grant truncate on table "public"."site_standard_documents_in_publications" to "anon"; 102 + grant update on table "public"."site_standard_documents_in_publications" to "anon"; 103 + grant delete on table "public"."site_standard_documents_in_publications" to "authenticated"; 104 + grant insert on table "public"."site_standard_documents_in_publications" to "authenticated"; 105 + grant references on table "public"."site_standard_documents_in_publications" to "authenticated"; 106 + grant select on table "public"."site_standard_documents_in_publications" to "authenticated"; 107 + grant trigger on table "public"."site_standard_documents_in_publications" to "authenticated"; 108 + grant truncate on table "public"."site_standard_documents_in_publications" to "authenticated"; 109 + grant update on table "public"."site_standard_documents_in_publications" to "authenticated"; 110 + grant delete on table "public"."site_standard_documents_in_publications" to "service_role"; 111 + grant insert on table "public"."site_standard_documents_in_publications" to "service_role"; 112 + grant references on table "public"."site_standard_documents_in_publications" to "service_role"; 113 + grant select on table "public"."site_standard_documents_in_publications" to "service_role"; 114 + grant trigger on table "public"."site_standard_documents_in_publications" to "service_role"; 115 + grant truncate on table "public"."site_standard_documents_in_publications" to "service_role"; 116 + grant update on table "public"."site_standard_documents_in_publications" to "service_role"; 117 + 118 + -- site_standard_subscriptions table (modeled off publication_subscriptions) 119 + create table "public"."site_standard_subscriptions" ( 120 + "publication" text not null, 121 + "identity" text not null, 122 + "created_at" timestamp with time zone not null default now(), 123 + "record" jsonb not null, 124 + "uri" text not null 125 + ); 126 + alter table "public"."site_standard_subscriptions" enable row level security; 127 + 128 + -- Primary key and unique indexes 129 + CREATE UNIQUE INDEX site_standard_subscriptions_pkey ON public.site_standard_subscriptions USING btree (publication, identity); 130 + CREATE UNIQUE INDEX site_standard_subscriptions_uri_key ON public.site_standard_subscriptions USING btree (uri); 131 + 132 + -- Add constraints 133 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_pkey" PRIMARY KEY using index "site_standard_subscriptions_pkey"; 134 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_uri_key" UNIQUE using index "site_standard_subscriptions_uri_key"; 135 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_publication_fkey" FOREIGN KEY (publication) REFERENCES site_standard_publications(uri) ON DELETE CASCADE not valid; 136 + alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_publication_fkey"; 137 + alter table "public"."site_standard_subscriptions" add constraint "site_standard_subscriptions_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 138 + alter table "public"."site_standard_subscriptions" validate constraint "site_standard_subscriptions_identity_fkey"; 139 + 140 + -- Grants for site_standard_subscriptions 141 + grant delete on table "public"."site_standard_subscriptions" to "anon"; 142 + grant insert on table "public"."site_standard_subscriptions" to "anon"; 143 + grant references on table "public"."site_standard_subscriptions" to "anon"; 144 + grant select on table "public"."site_standard_subscriptions" to "anon"; 145 + grant trigger on table "public"."site_standard_subscriptions" to "anon"; 146 + grant truncate on table "public"."site_standard_subscriptions" to "anon"; 147 + grant update on table "public"."site_standard_subscriptions" to "anon"; 148 + grant delete on table "public"."site_standard_subscriptions" to "authenticated"; 149 + grant insert on table "public"."site_standard_subscriptions" to "authenticated"; 150 + grant references on table "public"."site_standard_subscriptions" to "authenticated"; 151 + grant select on table "public"."site_standard_subscriptions" to "authenticated"; 152 + grant trigger on table "public"."site_standard_subscriptions" to "authenticated"; 153 + grant truncate on table "public"."site_standard_subscriptions" to "authenticated"; 154 + grant update on table "public"."site_standard_subscriptions" to "authenticated"; 155 + grant delete on table "public"."site_standard_subscriptions" to "service_role"; 156 + grant insert on table "public"."site_standard_subscriptions" to "service_role"; 157 + grant references on table "public"."site_standard_subscriptions" to "service_role"; 158 + grant select on table "public"."site_standard_subscriptions" to "service_role"; 159 + grant trigger on table "public"."site_standard_subscriptions" to "service_role"; 160 + grant truncate on table "public"."site_standard_subscriptions" to "service_role"; 161 + grant update on table "public"."site_standard_subscriptions" to "service_role";