···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- 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!!!
+5-2
actions/getIdentityData.ts
···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
···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
+115-54
actions/publishToPublication.ts
···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);
0940941- 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- }
000000000000000000000000000000000000974 }
975 }
976 }
···1026 };
1027 await supabaseServerClient.from("notifications").insert(notification);
1028 await pingIdentityToUpdateNotification(recipientDid);
00000000000000000000000000001029 }
1030}
···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+ }
945946+ const allBlocks = getAllBlocks(pages);
000000947948+ // 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+ }
00959960+ // 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);
975+976+ 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();
983+984+ 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();
997+998+ 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);
1007 }
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}
···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]>();
···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]>();
+6-2
app/(home-pages)/reader/getReaderFeed.ts
···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);
···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);
+5-1
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···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) => {
···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) => {
+17-1
app/[leaflet_id]/Footer.tsx
···8import { HomeButton } from "app/[leaflet_id]/actions/HomeButton";
9import { PublishButton } from "./actions/PublishButton";
10import { useEntitySetContext } from "components/EntitySetProvider";
11-import { HelpButton } from "app/[leaflet_id]/actions/HelpButton";
12import { Watermark } from "components/Watermark";
13import { BackToPubButton } from "./actions/BackToPubButton";
14import { useLeafletPublicationData } from "components/PageSWRDataProvider";
15import { useIdentityData } from "components/IdentityProvider";
0016000000000017export function LeafletFooter(props: { entityID: string }) {
18 let focusedBlock = useUIState((s) => s.focusedEntity);
019 let entity_set = useEntitySetContext();
20 let { identity } = useIdentityData();
21 let { data: pub } = useLeafletPublicationData();
002223 return (
24 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] ">
25 {focusedBlock &&
26 focusedBlock.entityType == "block" &&
027 entity_set.permissions.write ? (
28 <div
29 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom"
···34 <Toolbar
35 pageID={focusedBlock.parent}
36 blockID={focusedBlock.entityID}
037 />
38 </div>
39 ) : entity_set.permissions.write ? (
···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) {
···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) {
+21-3
components/Blocks/useBlockMouseHandlers.ts
···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 () => {
···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 () => {
···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 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 }
···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 }
···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({
+31-10
src/replicache/mutations.ts
···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,
···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,
···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+}
+10-2
src/utils/deleteBlock.ts
···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}
···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}
···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(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}
···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}