···2- it looks good on both mobile and desktop
3- it undo's like it ought to
4- it handles keyboard interactions reasonably well
05- no build errors!!!
···2- it looks good on both mobile and desktop
3- it undo's like it ought to
4- it handles keyboard interactions reasonably well
5+- it behaves as you would expect if you lock it
6- no build errors!!!
+2-5
actions/getIdentityData.ts
···3import { cookies } from "next/headers";
4import { supabaseServerClient } from "supabase/serverClient";
5import { cache } from "react";
6-import { deduplicateByUri } from "src/utils/deduplicateRecords";
7export const getIdentityData = cache(uncachedGetIdentityData);
8export async function uncachedGetIdentityData() {
9 let cookieStore = await cookies();
···45 if (!auth_res?.data?.identities) return null;
46 if (auth_res.data.identities.atp_did) {
47 //I should create a relationship table so I can do this in the above query
48- let { data: rawPublications } = await supabaseServerClient
49 .from("publications")
50 .select("*")
51 .eq("identity_did", auth_res.data.identities.atp_did);
52- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
53- const publications = deduplicateByUri(rawPublications || []);
54 return {
55 ...auth_res.data.identities,
56- publications,
57 };
58 }
59
···3import { cookies } from "next/headers";
4import { supabaseServerClient } from "supabase/serverClient";
5import { cache } from "react";
06export const getIdentityData = cache(uncachedGetIdentityData);
7export async function uncachedGetIdentityData() {
8 let cookieStore = await cookies();
···44 if (!auth_res?.data?.identities) return null;
45 if (auth_res.data.identities.atp_did) {
46 //I should create a relationship table so I can do this in the above query
47+ let { data: publications } = await supabaseServerClient
48 .from("publications")
49 .select("*")
50 .eq("identity_did", auth_res.data.identities.atp_did);
0051 return {
52 ...auth_res.data.identities,
53+ publications: publications || [],
54 };
55 }
56
+54-115
actions/publishToPublication.ts
···199 }
200201 // Determine the collection to use - preserve existing schema if updating
202- const existingCollection = existingDocUri
203- ? new AtUri(existingDocUri).collection
204- : undefined;
205 const documentType = getDocumentType(existingCollection);
206207 // Build the pages array (used by both formats)
···230 if (documentType === "site.standard.document") {
231 // site.standard.document format
232 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
233- const siteUri =
234- publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
235236 record = {
237 $type: "site.standard.document",
238 title: title || "Untitled",
239 site: siteUri,
240- path: "/" + rkey,
241 publishedAt:
242 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
243 ...(description && { description }),
···906 const mentionedDids = new Set<string>();
907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
909- const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
910911 // Extract pages from either format
912 let pages: PubLeafletContent.Main["pages"] | undefined;
···921922 if (!pages) return;
923924- // Helper to extract blocks from all pages (both linear and canvas)
925- function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
926- const blocks: (
927- | PubLeafletPagesLinearDocument.Block["block"]
928- | PubLeafletPagesCanvas.Block["block"]
929- )[] = [];
930- for (const page of pages) {
931- if (page.$type === "pub.leaflet.pages.linearDocument") {
932- const linearPage = page as PubLeafletPagesLinearDocument.Main;
933- for (const blockWrapper of linearPage.blocks) {
934- blocks.push(blockWrapper.block);
935- }
936- } else if (page.$type === "pub.leaflet.pages.canvas") {
937- const canvasPage = page as PubLeafletPagesCanvas.Main;
938- for (const blockWrapper of canvasPage.blocks) {
939- blocks.push(blockWrapper.block);
940- }
941- }
942- }
943- return blocks;
944- }
945-946- const allBlocks = getAllBlocks(pages);
947-948- // Extract mentions from all text blocks and embedded Bluesky posts
949- for (const block of allBlocks) {
950- // Check for embedded Bluesky posts
951- if (PubLeafletBlocksBskyPost.isMain(block)) {
952- const bskyPostUri = block.postRef.uri;
953- // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
954- const postAuthorDid = new AtUri(bskyPostUri).host;
955- if (postAuthorDid !== authorDid) {
956- embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
957- }
958- }
959-960- // Check for text blocks with mentions
961- if (block.$type === "pub.leaflet.blocks.text") {
962- const textBlock = block as PubLeafletBlocksText.Main;
963- if (textBlock.facets) {
964- for (const facet of textBlock.facets) {
965- for (const feature of facet.features) {
966- // Check for DID mentions
967- if (PubLeafletRichtextFacet.isDidMention(feature)) {
968- if (feature.did !== authorDid) {
969- mentionedDids.add(feature.did);
970- }
971- }
972- // Check for AT URI mentions (publications and documents)
973- if (PubLeafletRichtextFacet.isAtMention(feature)) {
974- const uri = new AtUri(feature.atURI);
975976- if (isPublicationCollection(uri.collection)) {
977- // Get the publication owner's DID
978- const { data: publication } = await supabaseServerClient
979- .from("publications")
980- .select("identity_did")
981- .eq("uri", feature.atURI)
982- .single();
983984- if (publication && publication.identity_did !== authorDid) {
985- mentionedPublications.set(
986- publication.identity_did,
987- feature.atURI,
988- );
989- }
990- } else if (isDocumentCollection(uri.collection)) {
991- // Get the document owner's DID
992- const { data: document } = await supabaseServerClient
993- .from("documents")
994- .select("uri, data")
995- .eq("uri", feature.atURI)
996- .single();
997998- if (document) {
999- const normalizedMentionedDoc = normalizeDocumentRecord(
1000- document.data,
1001- );
1002- // Get the author from the document URI (the DID is the host part)
1003- const mentionedUri = new AtUri(feature.atURI);
1004- const docAuthor = mentionedUri.host;
1005- if (normalizedMentionedDoc && docAuthor !== authorDid) {
1006- mentionedDocuments.set(docAuthor, feature.atURI);
001007 }
1008 }
1009 }
···1059 };
1060 await supabaseServerClient.from("notifications").insert(notification);
1061 await pingIdentityToUpdateNotification(recipientDid);
1062- }
1063-1064- // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
1065- if (embeddedBskyPosts.size > 0) {
1066- // Check which of the Bluesky post authors have Leaflet accounts
1067- const { data: identities } = await supabaseServerClient
1068- .from("identities")
1069- .select("atp_did")
1070- .in("atp_did", Array.from(embeddedBskyPosts.keys()));
1071-1072- const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
1073-1074- for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
1075- // Only notify if the post author has a Leaflet account
1076- if (leafletUserDids.has(postAuthorDid)) {
1077- const notification: Notification = {
1078- id: v7(),
1079- recipient: postAuthorDid,
1080- data: {
1081- type: "bsky_post_embed",
1082- document_uri: documentUri,
1083- bsky_post_uri: bskyPostUri,
1084- },
1085- };
1086- await supabaseServerClient.from("notifications").insert(notification);
1087- await pingIdentityToUpdateNotification(postAuthorDid);
1088- }
1089- }
1090 }
1091}
···199 }
200201 // Determine the collection to use - preserve existing schema if updating
202+ const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined;
00203 const documentType = getDocumentType(existingCollection);
204205 // Build the pages array (used by both formats)
···228 if (documentType === "site.standard.document") {
229 // site.standard.document format
230 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
231+ const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
0232233 record = {
234 $type: "site.standard.document",
235 title: title || "Untitled",
236 site: siteUri,
237+ path: rkey,
238 publishedAt:
239 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
240 ...(description && { description }),
···903 const mentionedDids = new Set<string>();
904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
0906907 // Extract pages from either format
908 let pages: PubLeafletContent.Main["pages"] | undefined;
···917918 if (!pages) return;
919920+ // Extract mentions from all text blocks in all pages
921+ for (const page of pages) {
922+ if (page.$type === "pub.leaflet.pages.linearDocument") {
923+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
924+ for (const blockWrapper of linearPage.blocks) {
925+ const block = blockWrapper.block;
926+ if (block.$type === "pub.leaflet.blocks.text") {
927+ const textBlock = block as PubLeafletBlocksText.Main;
928+ if (textBlock.facets) {
929+ for (const facet of textBlock.facets) {
930+ for (const feature of facet.features) {
931+ // Check for DID mentions
932+ if (PubLeafletRichtextFacet.isDidMention(feature)) {
933+ if (feature.did !== authorDid) {
934+ mentionedDids.add(feature.did);
935+ }
936+ }
937+ // Check for AT URI mentions (publications and documents)
938+ if (PubLeafletRichtextFacet.isAtMention(feature)) {
939+ const uri = new AtUri(feature.atURI);
0000000000000000000000000000000940941+ if (isPublicationCollection(uri.collection)) {
942+ // Get the publication owner's DID
943+ const { data: publication } = await supabaseServerClient
944+ .from("publications")
945+ .select("identity_did")
946+ .eq("uri", feature.atURI)
947+ .single();
948949+ if (publication && publication.identity_did !== authorDid) {
950+ mentionedPublications.set(
951+ publication.identity_did,
952+ feature.atURI,
953+ );
954+ }
955+ } else if (isDocumentCollection(uri.collection)) {
956+ // Get the document owner's DID
957+ const { data: document } = await supabaseServerClient
958+ .from("documents")
959+ .select("uri, data")
960+ .eq("uri", feature.atURI)
961+ .single();
962963+ if (document) {
964+ const normalizedMentionedDoc = normalizeDocumentRecord(
965+ document.data,
966+ );
967+ // Get the author from the document URI (the DID is the host part)
968+ const mentionedUri = new AtUri(feature.atURI);
969+ const docAuthor = mentionedUri.host;
970+ if (normalizedMentionedDoc && docAuthor !== authorDid) {
971+ mentionedDocuments.set(docAuthor, feature.atURI);
972+ }
973+ }
974 }
975 }
976 }
···1026 };
1027 await supabaseServerClient.from("notifications").insert(notification);
1028 await pingIdentityToUpdateNotification(recipientDid);
00000000000000000000000000001029 }
1030}
+1-5
app/(home-pages)/discover/getPublications.ts
···5 normalizePublicationRow,
6 hasValidPublication,
7} from "src/utils/normalizeRecords";
8-import { deduplicateByUri } from "src/utils/deduplicateRecords";
910export type Cursor = {
11 indexed_at?: string;
···43 return { publications: [], nextCursor: null };
44 }
4546- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
47- const dedupedPublications = deduplicateByUri(publications || []);
48-49 // Filter out publications without documents
50- const allPubs = dedupedPublications.filter(
51 (pub) => pub.documents_in_publications.length > 0,
52 );
53
···7 normalizeDocumentRecord,
8 normalizePublicationRecord,
9} from "src/utils/normalizeRecords";
10-import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
1112export type Cursor = {
13 indexed_at: string;
···39 );
40 }
4142- let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([
43 query,
44 supabaseServerClient
45 .from("publications")
···51 .eq("did", did)
52 .single(),
53 ]);
54-55- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
56- const docs = deduplicateByUriOrdered(rawDocs || []);
57- const pubs = deduplicateByUriOrdered(rawPubs || []);
5859 // Build a map of publications for quick lookup
60 let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
···7 normalizeDocumentRecord,
8 normalizePublicationRecord,
9} from "src/utils/normalizeRecords";
01011export type Cursor = {
12 indexed_at: string;
···38 );
39 }
4041+ let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([
42 query,
43 supabaseServerClient
44 .from("publications")
···50 .eq("did", did)
51 .single(),
52 ]);
00005354 // Build a map of publications for quick lookup
55 let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
+2-6
app/(home-pages)/reader/getReaderFeed.ts
···14 type NormalizedDocument,
15 type NormalizedPublication,
16} from "src/utils/normalizeRecords";
17-import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
1819export type Cursor = {
20 timestamp: string;
···46 `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`,
47 );
48 }
49- let { data: rawFeed, error } = await query;
50-51- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
52- const feed = deduplicateByUriOrdered(rawFeed || []);
5354 let posts = (
55 await Promise.all(
56- feed.map(async (post) => {
57 let pub = post.documents_in_publications[0].publications!;
58 let uri = new AtUri(post.uri);
59 let handle = await idResolver.did.resolve(uri.host);
···14 type NormalizedDocument,
15 type NormalizedPublication,
16} from "src/utils/normalizeRecords";
01718export type Cursor = {
19 timestamp: string;
···45 `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`,
46 );
47 }
48+ let { data: feed, error } = await query;
0004950 let posts = (
51 await Promise.all(
52+ feed?.map(async (post) => {
53 let pub = post.documents_in_publications[0].publications!;
54 let uri = new AtUri(post.uri);
55 let handle = await idResolver.did.resolve(uri.host);
+1-5
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···9 normalizeDocumentRecord,
10 normalizePublicationRecord,
11} from "src/utils/normalizeRecords";
12-import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
1314export async function getDocumentsByTag(
15 tag: string,
16): Promise<{ posts: Post[] }> {
17 // Query documents that have this tag
18- const { data: rawDocuments, error } = await supabaseServerClient
19 .from("documents")
20 .select(
21 `*,
···31 console.error("Error fetching documents by tag:", error);
32 return { posts: [] };
33 }
34-35- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
36- const documents = deduplicateByUriOrdered(rawDocuments || []);
3738 const posts = await Promise.all(
39 documents.map(async (doc) => {
···9 normalizeDocumentRecord,
10 normalizePublicationRecord,
11} from "src/utils/normalizeRecords";
01213export async function getDocumentsByTag(
14 tag: string,
15): Promise<{ posts: Post[] }> {
16 // Query documents that have this tag
17+ const { data: documents, error } = await supabaseServerClient
18 .from("documents")
19 .select(
20 `*,
···30 console.error("Error fetching documents by tag:", error);
31 return { posts: [] };
32 }
0003334 const posts = await Promise.all(
35 documents.map(async (doc) => {
···23) {
24 let { rep, undoManager } = useReplicache();
25 let entity_set = useEntitySetContext();
02627 let isSelected = useUIState((s) => {
28 let selectedBlocks = s.selectedBlocks;
···69 entity_set,
70 areYouSure,
71 setAreYouSure,
072 });
73 undoManager.endGroup();
74 };
75 window.addEventListener("keydown", listener);
76 return () => window.removeEventListener("keydown", listener);
77- }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]);
78}
7980type Args = {
81 e: KeyboardEvent;
082 props: BlockProps;
83 rep: Replicache<ReplicacheMutators>;
84 entity_set: { set: string };
···130}
131132let debounced: null | number = null;
133-async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) {
0000000134 // if this is a textBlock, let the textBlock/keymap handle the backspace
0135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead)
136 let el = e.target as HTMLElement;
137 if (
···143 if ((el as HTMLInputElement).value !== "") return;
144 }
145146- // if the block is a card, mailbox, rsvp, or poll...
147 if (
148 props.type === "card" ||
149 props.type === "mailbox" ||
150- props.type === "rsvp" ||
151- props.type === "poll"
152 ) {
153 // ...and areYouSure state is false, set it to true
154 if (!areYouSure) {
···23) {
24 let { rep, undoManager } = useReplicache();
25 let entity_set = useEntitySetContext();
26+ let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value;
2728 let isSelected = useUIState((s) => {
29 let selectedBlocks = s.selectedBlocks;
···70 entity_set,
71 areYouSure,
72 setAreYouSure,
73+ isLocked,
74 });
75 undoManager.endGroup();
76 };
77 window.addEventListener("keydown", listener);
78 return () => window.removeEventListener("keydown", listener);
79+ }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]);
80}
8182type Args = {
83 e: KeyboardEvent;
84+ isLocked: boolean;
85 props: BlockProps;
86 rep: Replicache<ReplicacheMutators>;
87 entity_set: { set: string };
···133}
134135let debounced: null | number = null;
136+async function Backspace({
137+ e,
138+ props,
139+ rep,
140+ areYouSure,
141+ setAreYouSure,
142+ isLocked,
143+}: Args) {
144 // if this is a textBlock, let the textBlock/keymap handle the backspace
145+ if (isLocked) return;
146 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead)
147 let el = e.target as HTMLElement;
148 if (
···154 if ((el as HTMLInputElement).value !== "") return;
155 }
156157+ // if the block is a card or mailbox...
158 if (
159 props.type === "card" ||
160 props.type === "mailbox" ||
161+ props.type === "rsvp"
0162 ) {
163 // ...and areYouSure state is false, set it to true
164 if (!areYouSure) {
+3-21
components/Blocks/useBlockMouseHandlers.ts
···1import { useSelectingMouse } from "components/SelectionManager/selectionState";
2-import { MouseEvent, useCallback } from "react";
3import { useUIState } from "src/useUIState";
4import { Block } from "./Block";
5import { isTextBlock } from "src/utils/isTextBlock";
···12import { elementId } from "src/utils/elementId";
1314let debounce: number | null = null;
15-16-// Track scrolling state for mobile
17-let isScrolling = false;
18-let scrollTimeout: number | null = null;
19-20-if (typeof window !== "undefined") {
21- window.addEventListener(
22- "scroll",
23- () => {
24- isScrolling = true;
25- if (scrollTimeout) window.clearTimeout(scrollTimeout);
26- scrollTimeout = window.setTimeout(() => {
27- isScrolling = false;
28- }, 150);
29- },
30- true,
31- );
32-}
33export function useBlockMouseHandlers(props: Block) {
34 let entity_set = useEntitySetContext();
35 let isMobile = useIsMobile();
···40 if ((e.target as Element).tagName === "BUTTON") return;
41 if ((e.target as Element).tagName === "SELECT") return;
42 if ((e.target as Element).tagName === "OPTION") return;
43- if (isMobile && isScrolling) return;
44 if (!entity_set.permissions.write) return;
45 useSelectingMouse.setState({ start: props.value });
46 if (e.shiftKey) {
···75 );
76 let onMouseEnter = useCallback(
77 async (e: MouseEvent) => {
78- if (isMobile && isScrolling) return;
79 if (!entity_set.permissions.write) return;
80 if (debounce) window.clearTimeout(debounce);
81 debounce = window.setTimeout(async () => {
···1import { useSelectingMouse } from "components/SelectionManager/selectionState";
2+import { MouseEvent, useCallback, useRef } from "react";
3import { useUIState } from "src/useUIState";
4import { Block } from "./Block";
5import { isTextBlock } from "src/utils/isTextBlock";
···12import { elementId } from "src/utils/elementId";
1314let debounce: number | null = null;
00000000000000000015export function useBlockMouseHandlers(props: Block) {
16 let entity_set = useEntitySetContext();
17 let isMobile = useIsMobile();
···22 if ((e.target as Element).tagName === "BUTTON") return;
23 if ((e.target as Element).tagName === "SELECT") return;
24 if ((e.target as Element).tagName === "OPTION") return;
25+ if (isMobile) return;
26 if (!entity_set.permissions.write) return;
27 useSelectingMouse.setState({ start: props.value });
28 if (e.shiftKey) {
···57 );
58 let onMouseEnter = useCallback(
59 async (e: MouseEvent) => {
60+ if (isMobile) return;
61 if (!entity_set.permissions.write) return;
62 if (debounce) window.clearTimeout(debounce);
63 debounce = window.setTimeout(async () => {
···17import { schema } from "../Blocks/TextBlock/schema";
18import { MarkType } from "prosemirror-model";
19import { useSelectingMouse, getSortedSelection } from "./selectionState";
20-import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock";
2122//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
23// How does this relate to *when dragging* ?
···241 shift: true,
242 key: ["ArrowDown", "J"],
243 handler: async () => {
244- if (!rep) return;
245- await moveBlockDown(rep, entity_set.set);
000000000000000000000000000246 },
247 },
248 {
···250 shift: true,
251 key: ["ArrowUp", "K"],
252 handler: async () => {
253- if (!rep) return;
254- await moveBlockUp(rep);
000000000000000000000000000000000000255 },
256 },
257
···14 */
1516import type * as PubLeafletDocument from "../api/types/pub/leaflet/document";
17-import * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
18import type * as PubLeafletContent from "../api/types/pub/leaflet/content";
19import type * as SiteStandardDocument from "../api/types/site/standard/document";
20import type * as SiteStandardPublication from "../api/types/site/standard/publication";
···31};
3233// Normalized publication type - uses the generated site.standard.publication type
34-// with the theme narrowed to only the valid pub.leaflet.publication#theme type
35-// (isTheme validates that $type is present, so we use $Typed)
36-// Note: We explicitly list fields rather than using Omit because the generated Record type
37-// has an index signature [k: string]: unknown that interferes with property typing
38-export type NormalizedPublication = {
39- $type: "site.standard.publication";
40- name: string;
41- url: string;
42- description?: string;
43- icon?: SiteStandardPublication.Record["icon"];
44- basicTheme?: SiteStandardThemeBasic.Main;
45- theme?: $Typed<PubLeafletPublication.Theme>;
46- preferences?: SiteStandardPublication.Preferences;
47-};
4849/**
50 * Checks if the record is a pub.leaflet.document
···223): NormalizedPublication | null {
224 if (!record || typeof record !== "object") return null;
225226- // Pass through site.standard records directly, but validate the theme
227 if (isStandardPublication(record)) {
228- // Validate theme - only keep if it's a valid pub.leaflet.publication#theme
229- const theme = PubLeafletPublication.isTheme(record.theme)
230- ? (record.theme as $Typed<PubLeafletPublication.Theme>)
231- : undefined;
232- return {
233- ...record,
234- theme,
235- };
236 }
237238 if (isLeafletPublication(record)) {
···245246 const basicTheme = leafletThemeToBasicTheme(record.theme);
247248- // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set
249- // For legacy records without $type, add it during normalization
250- let theme: $Typed<PubLeafletPublication.Theme> | undefined;
251- if (record.theme) {
252- if (PubLeafletPublication.isTheme(record.theme)) {
253- theme = record.theme as $Typed<PubLeafletPublication.Theme>;
254- } else {
255- // Legacy theme without $type - add it
256- theme = {
257- ...record.theme,
258- $type: "pub.leaflet.publication#theme",
259- };
260- }
261- }
262-263 // Convert preferences to site.standard format (strip/replace $type)
264 const preferences: SiteStandardPublication.Preferences | undefined =
265 record.preferences
···278 description: record.description,
279 icon: record.icon,
280 basicTheme,
281- theme,
282 preferences,
283 };
284 }
···14 */
1516import type * as PubLeafletDocument from "../api/types/pub/leaflet/document";
17+import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
18import type * as PubLeafletContent from "../api/types/pub/leaflet/content";
19import type * as SiteStandardDocument from "../api/types/site/standard/document";
20import type * as SiteStandardPublication from "../api/types/site/standard/publication";
···31};
3233// Normalized publication type - uses the generated site.standard.publication type
34+export type NormalizedPublication = SiteStandardPublication.Record;
00000000000003536/**
37 * Checks if the record is a pub.leaflet.document
···210): NormalizedPublication | null {
211 if (!record || typeof record !== "object") return null;
212213+ // Pass through site.standard records directly
214 if (isStandardPublication(record)) {
215+ return record;
0000000216 }
217218 if (isLeafletPublication(record)) {
···225226 const basicTheme = leafletThemeToBasicTheme(record.theme);
227000000000000000228 // Convert preferences to site.standard format (strip/replace $type)
229 const preferences: SiteStandardPublication.Preferences | undefined =
230 record.preferences
···243 description: record.description,
244 icon: record.icon,
245 basicTheme,
246+ theme: record.theme,
247 preferences,
248 };
249 }
···67 textData.value = base64.fromByteArray(updateBytes);
68 }
69 }
70- } else if (f.id) {
71- // For cardinality "many" with an explicit ID, fetch the existing fact
72- // so undo can restore it instead of deleting
73- let fact = await tx.get(f.id);
74- if (fact) {
75- existingFact = [fact as Fact<any>];
76- }
77 }
78 if (!ignoreUndo)
79 undoManager.add({
···4import { SupabaseClient } from "@supabase/supabase-js";
5import { Database } from "supabase/database.types";
6import { generateKeyBetween } from "fractional-indexing";
7-import { v7 } from "uuid";
89export type MutationContext = {
10 permission_token_id: string;
···308 { blockEntity: string } | { blockEntity: string }[]
309> = async (args, ctx) => {
310 for (let block of [args].flat()) {
00000311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
312 await ctx.runOnServer(async ({ supabase }) => {
313 if (image) {
···423 },
424 });
425};
426-const moveBlockDown: Mutation<{
427- entityID: string;
428- parent: string;
429- permission_set?: string;
430-}> = async (args, ctx) => {
431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
432 (a, b) => (a.data.position > b.data.position ? 1 : -1),
433 );
434 let index = children.findIndex((f) => f.data.value === args.entityID);
435 if (index === -1) return;
436 let next = children[index + 1];
437- if (!next) {
438- // If this is the last block, create a new empty block above it using the addBlock helper
439- if (!args.permission_set) return; // Can't create block without permission_set
440-441- let newEntityID = v7();
442- let previousBlock = children[index - 1];
443- let position = generateKeyBetween(
444- previousBlock?.data.position || null,
445- children[index].data.position,
446- );
447-448- // Call the addBlock mutation helper directly
449- await addBlock(
450- {
451- parent: args.parent,
452- permission_set: args.permission_set,
453- factID: v7(),
454- type: "text",
455- newEntityID: newEntityID,
456- position: position,
457- },
458- ctx,
459- );
460- return;
461- }
462 await ctx.retractFact(children[index].id);
463 await ctx.assertFact({
464 id: children[index].id,
···4import { SupabaseClient } from "@supabase/supabase-js";
5import { Database } from "supabase/database.types";
6import { generateKeyBetween } from "fractional-indexing";
078export type MutationContext = {
9 permission_token_id: string;
···307 { blockEntity: string } | { blockEntity: string }[]
308> = async (args, ctx) => {
309 for (let block of [args].flat()) {
310+ let [isLocked] = await ctx.scanIndex.eav(
311+ block.blockEntity,
312+ "block/is-locked",
313+ );
314+ if (isLocked?.data.value) continue;
315 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
316 await ctx.runOnServer(async ({ supabase }) => {
317 if (image) {
···427 },
428 });
429};
430+const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async (
431+ args,
432+ ctx,
433+) => {
0434 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
435 (a, b) => (a.data.position > b.data.position ? 1 : -1),
436 );
437 let index = children.findIndex((f) => f.data.value === args.entityID);
438 if (index === -1) return;
439 let next = children[index + 1];
440+ if (!next) return;
000000000000000000000000441 await ctx.retractFact(children[index].id);
442 await ctx.assertFact({
443 id: children[index].id,
-122
src/utils/deduplicateRecords.ts
···1-/**
2- * Utilities for deduplicating records that may exist under both
3- * pub.leaflet.* and site.standard.* namespaces.
4- *
5- * After the migration to site.standard.*, records can exist in both namespaces
6- * with the same DID and rkey. This utility deduplicates them, preferring
7- * site.standard.* records when available.
8- */
9-10-import { AtUri } from "@atproto/syntax";
11-12-/**
13- * Extracts the identity key (DID + rkey) from an AT URI.
14- * This key uniquely identifies a record across namespaces.
15- *
16- * @example
17- * getRecordIdentityKey("at://did:plc:abc/pub.leaflet.document/3abc")
18- * // Returns: "did:plc:abc/3abc"
19- *
20- * getRecordIdentityKey("at://did:plc:abc/site.standard.document/3abc")
21- * // Returns: "did:plc:abc/3abc" (same key, different namespace)
22- */
23-function getRecordIdentityKey(uri: string): string | null {
24- try {
25- const parsed = new AtUri(uri);
26- return `${parsed.host}/${parsed.rkey}`;
27- } catch {
28- return null;
29- }
30-}
31-32-/**
33- * Checks if a URI is from the site.standard namespace.
34- */
35-function isSiteStandardUri(uri: string): boolean {
36- return uri.includes("/site.standard.");
37-}
38-39-/**
40- * Deduplicates an array of records that have a `uri` property.
41- *
42- * When records exist under both pub.leaflet.* and site.standard.* namespaces
43- * (same DID and rkey), this function keeps only the site.standard version.
44- *
45- * @param records - Array of records with a `uri` property
46- * @returns Deduplicated array, preferring site.standard records
47- *
48- * @example
49- * const docs = [
50- * { uri: "at://did:plc:abc/pub.leaflet.document/3abc", data: {...} },
51- * { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
52- * { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
53- * ];
54- * const deduped = deduplicateByUri(docs);
55- * // Returns: [
56- * // { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
57- * // { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
58- * // ]
59- */
60-export function deduplicateByUri<T extends { uri: string }>(records: T[]): T[] {
61- const recordsByKey = new Map<string, T>();
62-63- for (const record of records) {
64- const key = getRecordIdentityKey(record.uri);
65- if (!key) {
66- // Invalid URI, keep the record as-is
67- continue;
68- }
69-70- const existing = recordsByKey.get(key);
71- if (!existing) {
72- recordsByKey.set(key, record);
73- } else {
74- // Prefer site.standard records over pub.leaflet records
75- if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.uri)) {
76- recordsByKey.set(key, record);
77- }
78- // If both are same namespace or existing is already site.standard, keep existing
79- }
80- }
81-82- return Array.from(recordsByKey.values());
83-}
84-85-/**
86- * Deduplicates records while preserving the original order based on the first
87- * occurrence of each unique record.
88- *
89- * Same deduplication logic as deduplicateByUri, but maintains insertion order.
90- *
91- * @param records - Array of records with a `uri` property
92- * @returns Deduplicated array in original order, preferring site.standard records
93- */
94-export function deduplicateByUriOrdered<T extends { uri: string }>(
95- records: T[]
96-): T[] {
97- const recordsByKey = new Map<string, { record: T; index: number }>();
98-99- for (let i = 0; i < records.length; i++) {
100- const record = records[i];
101- const key = getRecordIdentityKey(record.uri);
102- if (!key) {
103- continue;
104- }
105-106- const existing = recordsByKey.get(key);
107- if (!existing) {
108- recordsByKey.set(key, { record, index: i });
109- } else {
110- // Prefer site.standard records over pub.leaflet records
111- if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.record.uri)) {
112- // Replace with site.standard but keep original position
113- recordsByKey.set(key, { record, index: existing.index });
114- }
115- }
116- }
117-118- // Sort by original index to maintain order
119- return Array.from(recordsByKey.values())
120- .sort((a, b) => a.index - b.index)
121- .map((entry) => entry.record);
122-}
···4import { scanIndex } from "src/replicache/utils";
5import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6import { focusBlock } from "src/utils/focusBlock";
7-import { UndoManager } from "src/undoManager";
89export async function deleteBlock(
10 entities: string[],
11 rep: Replicache<ReplicacheMutators>,
12- undoManager?: UndoManager,
13) {
14 // get what pagess we need to close as a result of deleting this block
15 let pagesToClose = [] as string[];
···34 }
35 }
3637- // figure out what to focus
038 let focusedBlock = useUIState.getState().focusedEntity;
39 let parent =
40 focusedBlock?.entityType === "page"
···45 let parentType = await rep?.query((tx) =>
46 scanIndex(tx).eav(parent, "page/type"),
47 );
48- // if the page is a canvas, focus the page
49 if (parentType[0]?.data.value === "canvas") {
50 useUIState
51 .getState()
52 .setFocusedBlock({ entityType: "page", entityID: parent });
53 useUIState.getState().setSelectedBlocks([]);
54 } else {
55- // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block)
56 let siblings =
57 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
58···108 }
109 }
110111- // close the pages
112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113- undoManager && undoManager.startGroup();
114-115- // delete the blocks
116 await Promise.all(
117 entities.map((entity) =>
118 rep?.mutate.removeBlock({
···120 }),
121 ),
122 );
123- undoManager && undoManager.endGroup();
124}
···4import { scanIndex } from "src/replicache/utils";
5import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6import { focusBlock } from "src/utils/focusBlock";
078export async function deleteBlock(
9 entities: string[],
10 rep: Replicache<ReplicacheMutators>,
011) {
12 // get what pagess we need to close as a result of deleting this block
13 let pagesToClose = [] as string[];
···32 }
33 }
3435+ // 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"
···44 let parentType = await rep?.query((tx) =>
45 scanIndex(tx).eav(parent, "page/type"),
46 );
047 if (parentType[0]?.data.value === "canvas") {
48 useUIState
49 .getState()
50 .setFocusedBlock({ entityType: "page", entityID: parent });
51 useUIState.getState().setSelectedBlocks([]);
52 } else {
053 let siblings =
54 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
55···105 }
106 }
1070108 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
000109 await Promise.all(
110 entities.map((entity) =>
111 rep?.mutate.removeBlock({
···113 }),
114 ),
115 );
0116}
+1-3
src/utils/focusBlock.ts
···48 }
4950 if (pos?.offset !== undefined) {
51- // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page.
52- // idk how to fix so i'm giving up -- celine
53- // el?.focus();
54 requestAnimationFrame(() => {
55 el?.setSelectionRange(pos.offset, pos.offset);
56 });
···18 * or site.standard.publication namespaces.
19 */
20export function publicationUriFilter(did: string, rkey: string): string {
21- const standard = AtUri.make(
22- did,
23- ids.SiteStandardPublication,
24- rkey,
25- ).toString();
26 const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString();
27 return `uri.eq.${standard},uri.eq.${legacy}`;
28}
···31 * Returns an OR filter string for Supabase queries to match a publication by name
32 * or by either namespace URI. Used when the rkey might be the publication name.
33 */
34-export function publicationNameOrUriFilter(
35- did: string,
36- nameOrRkey: string,
37-): string {
38- let standard, legacy;
39- if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(nameOrRkey)) {
40- standard = AtUri.make(
41- did,
42- ids.SiteStandardPublication,
43- nameOrRkey,
44- ).toString();
45- legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString();
46- }
47- return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`;
48}
···18 * or site.standard.publication namespaces.
19 */
20export function publicationUriFilter(did: string, rkey: string): string {
21+ const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString();
000022 const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString();
23 return `uri.eq.${standard},uri.eq.${legacy}`;
24}
···27 * Returns an OR filter string for Supabase queries to match a publication by name
28 * or by either namespace URI. Used when the rkey might be the publication name.
29 */
30+export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string {
31+ const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString();
32+ const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString();
33+ return `name.eq.${nameOrRkey},uri.eq.${standard},uri.eq.${legacy}`;
000000000034}