···22- it looks good on both mobile and desktop
33- it undo's like it ought to
44- it handles keyboard interactions reasonably well
55-- it behaves as you would expect if you lock it
65- no build errors!!!
+5-2
actions/getIdentityData.ts
···33import { cookies } from "next/headers";
44import { supabaseServerClient } from "supabase/serverClient";
55import { cache } from "react";
66+import { deduplicateByUri } from "src/utils/deduplicateRecords";
67export const getIdentityData = cache(uncachedGetIdentityData);
78export async function uncachedGetIdentityData() {
89 let cookieStore = await cookies();
···4445 if (!auth_res?.data?.identities) return null;
4546 if (auth_res.data.identities.atp_did) {
4647 //I should create a relationship table so I can do this in the above query
4747- let { data: publications } = await supabaseServerClient
4848+ let { data: rawPublications } = await supabaseServerClient
4849 .from("publications")
4950 .select("*")
5051 .eq("identity_did", auth_res.data.identities.atp_did);
5252+ // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
5353+ const publications = deduplicateByUri(rawPublications || []);
5154 return {
5255 ...auth_res.data.identities,
5353- publications: publications || [],
5656+ publications,
5457 };
5558 }
5659
+115-54
actions/publishToPublication.ts
···199199 }
200200201201 // Determine the collection to use - preserve existing schema if updating
202202- const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined;
202202+ const existingCollection = existingDocUri
203203+ ? new AtUri(existingDocUri).collection
204204+ : undefined;
203205 const documentType = getDocumentType(existingCollection);
204206205207 // Build the pages array (used by both formats)
···228230 if (documentType === "site.standard.document") {
229231 // site.standard.document format
230232 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
231231- const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
233233+ const siteUri =
234234+ publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
232235233236 record = {
234237 $type: "site.standard.document",
235238 title: title || "Untitled",
236239 site: siteUri,
237237- path: rkey,
240240+ path: "/" + rkey,
238241 publishedAt:
239242 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
240243 ...(description && { description }),
···903906 const mentionedDids = new Set<string>();
904907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
905908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
909909+ const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
906910907911 // Extract pages from either format
908912 let pages: PubLeafletContent.Main["pages"] | undefined;
···917921918922 if (!pages) return;
919923920920- // Extract mentions from all text blocks in all pages
921921- for (const page of pages) {
922922- if (page.$type === "pub.leaflet.pages.linearDocument") {
923923- const linearPage = page as PubLeafletPagesLinearDocument.Main;
924924- for (const blockWrapper of linearPage.blocks) {
925925- const block = blockWrapper.block;
926926- if (block.$type === "pub.leaflet.blocks.text") {
927927- const textBlock = block as PubLeafletBlocksText.Main;
928928- if (textBlock.facets) {
929929- for (const facet of textBlock.facets) {
930930- for (const feature of facet.features) {
931931- // Check for DID mentions
932932- if (PubLeafletRichtextFacet.isDidMention(feature)) {
933933- if (feature.did !== authorDid) {
934934- mentionedDids.add(feature.did);
935935- }
936936- }
937937- // Check for AT URI mentions (publications and documents)
938938- if (PubLeafletRichtextFacet.isAtMention(feature)) {
939939- const uri = new AtUri(feature.atURI);
924924+ // Helper to extract blocks from all pages (both linear and canvas)
925925+ function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
926926+ const blocks: (
927927+ | PubLeafletPagesLinearDocument.Block["block"]
928928+ | PubLeafletPagesCanvas.Block["block"]
929929+ )[] = [];
930930+ for (const page of pages) {
931931+ if (page.$type === "pub.leaflet.pages.linearDocument") {
932932+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
933933+ for (const blockWrapper of linearPage.blocks) {
934934+ blocks.push(blockWrapper.block);
935935+ }
936936+ } else if (page.$type === "pub.leaflet.pages.canvas") {
937937+ const canvasPage = page as PubLeafletPagesCanvas.Main;
938938+ for (const blockWrapper of canvasPage.blocks) {
939939+ blocks.push(blockWrapper.block);
940940+ }
941941+ }
942942+ }
943943+ return blocks;
944944+ }
940945941941- if (isPublicationCollection(uri.collection)) {
942942- // Get the publication owner's DID
943943- const { data: publication } = await supabaseServerClient
944944- .from("publications")
945945- .select("identity_did")
946946- .eq("uri", feature.atURI)
947947- .single();
946946+ const allBlocks = getAllBlocks(pages);
948947949949- if (publication && publication.identity_did !== authorDid) {
950950- mentionedPublications.set(
951951- publication.identity_did,
952952- feature.atURI,
953953- );
954954- }
955955- } else if (isDocumentCollection(uri.collection)) {
956956- // Get the document owner's DID
957957- const { data: document } = await supabaseServerClient
958958- .from("documents")
959959- .select("uri, data")
960960- .eq("uri", feature.atURI)
961961- .single();
948948+ // Extract mentions from all text blocks and embedded Bluesky posts
949949+ for (const block of allBlocks) {
950950+ // Check for embedded Bluesky posts
951951+ if (PubLeafletBlocksBskyPost.isMain(block)) {
952952+ const bskyPostUri = block.postRef.uri;
953953+ // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
954954+ const postAuthorDid = new AtUri(bskyPostUri).host;
955955+ if (postAuthorDid !== authorDid) {
956956+ embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
957957+ }
958958+ }
962959963963- if (document) {
964964- const normalizedMentionedDoc = normalizeDocumentRecord(
965965- document.data,
966966- );
967967- // Get the author from the document URI (the DID is the host part)
968968- const mentionedUri = new AtUri(feature.atURI);
969969- const docAuthor = mentionedUri.host;
970970- if (normalizedMentionedDoc && docAuthor !== authorDid) {
971971- mentionedDocuments.set(docAuthor, feature.atURI);
972972- }
973973- }
960960+ // Check for text blocks with mentions
961961+ if (block.$type === "pub.leaflet.blocks.text") {
962962+ const textBlock = block as PubLeafletBlocksText.Main;
963963+ if (textBlock.facets) {
964964+ for (const facet of textBlock.facets) {
965965+ for (const feature of facet.features) {
966966+ // Check for DID mentions
967967+ if (PubLeafletRichtextFacet.isDidMention(feature)) {
968968+ if (feature.did !== authorDid) {
969969+ mentionedDids.add(feature.did);
970970+ }
971971+ }
972972+ // Check for AT URI mentions (publications and documents)
973973+ if (PubLeafletRichtextFacet.isAtMention(feature)) {
974974+ const uri = new AtUri(feature.atURI);
975975+976976+ if (isPublicationCollection(uri.collection)) {
977977+ // Get the publication owner's DID
978978+ const { data: publication } = await supabaseServerClient
979979+ .from("publications")
980980+ .select("identity_did")
981981+ .eq("uri", feature.atURI)
982982+ .single();
983983+984984+ if (publication && publication.identity_did !== authorDid) {
985985+ mentionedPublications.set(
986986+ publication.identity_did,
987987+ feature.atURI,
988988+ );
989989+ }
990990+ } else if (isDocumentCollection(uri.collection)) {
991991+ // Get the document owner's DID
992992+ const { data: document } = await supabaseServerClient
993993+ .from("documents")
994994+ .select("uri, data")
995995+ .eq("uri", feature.atURI)
996996+ .single();
997997+998998+ if (document) {
999999+ const normalizedMentionedDoc = normalizeDocumentRecord(
10001000+ document.data,
10011001+ );
10021002+ // Get the author from the document URI (the DID is the host part)
10031003+ const mentionedUri = new AtUri(feature.atURI);
10041004+ const docAuthor = mentionedUri.host;
10051005+ if (normalizedMentionedDoc && docAuthor !== authorDid) {
10061006+ mentionedDocuments.set(docAuthor, feature.atURI);
9741007 }
9751008 }
9761009 }
···10261059 };
10271060 await supabaseServerClient.from("notifications").insert(notification);
10281061 await pingIdentityToUpdateNotification(recipientDid);
10621062+ }
10631063+10641064+ // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
10651065+ if (embeddedBskyPosts.size > 0) {
10661066+ // Check which of the Bluesky post authors have Leaflet accounts
10671067+ const { data: identities } = await supabaseServerClient
10681068+ .from("identities")
10691069+ .select("atp_did")
10701070+ .in("atp_did", Array.from(embeddedBskyPosts.keys()));
10711071+10721072+ const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
10731073+10741074+ for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
10751075+ // Only notify if the post author has a Leaflet account
10761076+ if (leafletUserDids.has(postAuthorDid)) {
10771077+ const notification: Notification = {
10781078+ id: v7(),
10791079+ recipient: postAuthorDid,
10801080+ data: {
10811081+ type: "bsky_post_embed",
10821082+ document_uri: documentUri,
10831083+ bsky_post_uri: bskyPostUri,
10841084+ },
10851085+ };
10861086+ await supabaseServerClient.from("notifications").insert(notification);
10871087+ await pingIdentityToUpdateNotification(postAuthorDid);
10881088+ }
10891089+ }
10291090 }
10301091}
+5-1
app/(home-pages)/discover/getPublications.ts
···55 normalizePublicationRow,
66 hasValidPublication,
77} from "src/utils/normalizeRecords";
88+import { deduplicateByUri } from "src/utils/deduplicateRecords";
89910export type Cursor = {
1011 indexed_at?: string;
···4243 return { publications: [], nextCursor: null };
4344 }
44454646+ // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
4747+ const dedupedPublications = deduplicateByUri(publications || []);
4848+4549 // Filter out publications without documents
4646- const allPubs = (publications || []).filter(
5050+ const allPubs = dedupedPublications.filter(
4751 (pub) => pub.documents_in_publications.length > 0,
4852 );
4953
···1414 */
15151616import type * as PubLeafletDocument from "../api/types/pub/leaflet/document";
1717-import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
1717+import * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
1818import type * as PubLeafletContent from "../api/types/pub/leaflet/content";
1919import type * as SiteStandardDocument from "../api/types/site/standard/document";
2020import type * as SiteStandardPublication from "../api/types/site/standard/publication";
···3131};
32323333// Normalized publication type - uses the generated site.standard.publication type
3434-export type NormalizedPublication = SiteStandardPublication.Record;
3434+// with the theme narrowed to only the valid pub.leaflet.publication#theme type
3535+// (isTheme validates that $type is present, so we use $Typed)
3636+// Note: We explicitly list fields rather than using Omit because the generated Record type
3737+// has an index signature [k: string]: unknown that interferes with property typing
3838+export type NormalizedPublication = {
3939+ $type: "site.standard.publication";
4040+ name: string;
4141+ url: string;
4242+ description?: string;
4343+ icon?: SiteStandardPublication.Record["icon"];
4444+ basicTheme?: SiteStandardThemeBasic.Main;
4545+ theme?: $Typed<PubLeafletPublication.Theme>;
4646+ preferences?: SiteStandardPublication.Preferences;
4747+};
35483649/**
3750 * Checks if the record is a pub.leaflet.document
···210223): NormalizedPublication | null {
211224 if (!record || typeof record !== "object") return null;
212225213213- // Pass through site.standard records directly
226226+ // Pass through site.standard records directly, but validate the theme
214227 if (isStandardPublication(record)) {
215215- return record;
228228+ // Validate theme - only keep if it's a valid pub.leaflet.publication#theme
229229+ const theme = PubLeafletPublication.isTheme(record.theme)
230230+ ? (record.theme as $Typed<PubLeafletPublication.Theme>)
231231+ : undefined;
232232+ return {
233233+ ...record,
234234+ theme,
235235+ };
216236 }
217237218238 if (isLeafletPublication(record)) {
···225245226246 const basicTheme = leafletThemeToBasicTheme(record.theme);
227247248248+ // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set
249249+ // For legacy records without $type, add it during normalization
250250+ let theme: $Typed<PubLeafletPublication.Theme> | undefined;
251251+ if (record.theme) {
252252+ if (PubLeafletPublication.isTheme(record.theme)) {
253253+ theme = record.theme as $Typed<PubLeafletPublication.Theme>;
254254+ } else {
255255+ // Legacy theme without $type - add it
256256+ theme = {
257257+ ...record.theme,
258258+ $type: "pub.leaflet.publication#theme",
259259+ };
260260+ }
261261+ }
262262+228263 // Convert preferences to site.standard format (strip/replace $type)
229264 const preferences: SiteStandardPublication.Preferences | undefined =
230265 record.preferences
···243278 description: record.description,
244279 icon: record.icon,
245280 basicTheme,
246246- theme: record.theme,
281281+ theme,
247282 preferences,
248283 };
249284 }
···6767 textData.value = base64.fromByteArray(updateBytes);
6868 }
6969 }
7070+ } else if (f.id) {
7171+ // For cardinality "many" with an explicit ID, fetch the existing fact
7272+ // so undo can restore it instead of deleting
7373+ let fact = await tx.get(f.id);
7474+ if (fact) {
7575+ existingFact = [fact as Fact<any>];
7676+ }
7077 }
7178 if (!ignoreUndo)
7279 undoManager.add({
+31-10
src/replicache/mutations.ts
···44import { SupabaseClient } from "@supabase/supabase-js";
55import { Database } from "supabase/database.types";
66import { generateKeyBetween } from "fractional-indexing";
77+import { v7 } from "uuid";
7889export type MutationContext = {
910 permission_token_id: string;
···307308 { blockEntity: string } | { blockEntity: string }[]
308309> = async (args, ctx) => {
309310 for (let block of [args].flat()) {
310310- let [isLocked] = await ctx.scanIndex.eav(
311311- block.blockEntity,
312312- "block/is-locked",
313313- );
314314- if (isLocked?.data.value) continue;
315311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
316312 await ctx.runOnServer(async ({ supabase }) => {
317313 if (image) {
···427423 },
428424 });
429425};
430430-const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async (
431431- args,
432432- ctx,
433433-) => {
426426+const moveBlockDown: Mutation<{
427427+ entityID: string;
428428+ parent: string;
429429+ permission_set?: string;
430430+}> = async (args, ctx) => {
434431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
435432 (a, b) => (a.data.position > b.data.position ? 1 : -1),
436433 );
437434 let index = children.findIndex((f) => f.data.value === args.entityID);
438435 if (index === -1) return;
439436 let next = children[index + 1];
440440- if (!next) return;
437437+ if (!next) {
438438+ // If this is the last block, create a new empty block above it using the addBlock helper
439439+ if (!args.permission_set) return; // Can't create block without permission_set
440440+441441+ let newEntityID = v7();
442442+ let previousBlock = children[index - 1];
443443+ let position = generateKeyBetween(
444444+ previousBlock?.data.position || null,
445445+ children[index].data.position,
446446+ );
447447+448448+ // Call the addBlock mutation helper directly
449449+ await addBlock(
450450+ {
451451+ parent: args.parent,
452452+ permission_set: args.permission_set,
453453+ factID: v7(),
454454+ type: "text",
455455+ newEntityID: newEntityID,
456456+ position: position,
457457+ },
458458+ ctx,
459459+ );
460460+ return;
461461+ }
441462 await ctx.retractFact(children[index].id);
442463 await ctx.assertFact({
443464 id: children[index].id,
+122
src/utils/deduplicateRecords.ts
···11+/**
22+ * Utilities for deduplicating records that may exist under both
33+ * pub.leaflet.* and site.standard.* namespaces.
44+ *
55+ * After the migration to site.standard.*, records can exist in both namespaces
66+ * with the same DID and rkey. This utility deduplicates them, preferring
77+ * site.standard.* records when available.
88+ */
99+1010+import { AtUri } from "@atproto/syntax";
1111+1212+/**
1313+ * Extracts the identity key (DID + rkey) from an AT URI.
1414+ * This key uniquely identifies a record across namespaces.
1515+ *
1616+ * @example
1717+ * getRecordIdentityKey("at://did:plc:abc/pub.leaflet.document/3abc")
1818+ * // Returns: "did:plc:abc/3abc"
1919+ *
2020+ * getRecordIdentityKey("at://did:plc:abc/site.standard.document/3abc")
2121+ * // Returns: "did:plc:abc/3abc" (same key, different namespace)
2222+ */
2323+function getRecordIdentityKey(uri: string): string | null {
2424+ try {
2525+ const parsed = new AtUri(uri);
2626+ return `${parsed.host}/${parsed.rkey}`;
2727+ } catch {
2828+ return null;
2929+ }
3030+}
3131+3232+/**
3333+ * Checks if a URI is from the site.standard namespace.
3434+ */
3535+function isSiteStandardUri(uri: string): boolean {
3636+ return uri.includes("/site.standard.");
3737+}
3838+3939+/**
4040+ * Deduplicates an array of records that have a `uri` property.
4141+ *
4242+ * When records exist under both pub.leaflet.* and site.standard.* namespaces
4343+ * (same DID and rkey), this function keeps only the site.standard version.
4444+ *
4545+ * @param records - Array of records with a `uri` property
4646+ * @returns Deduplicated array, preferring site.standard records
4747+ *
4848+ * @example
4949+ * const docs = [
5050+ * { uri: "at://did:plc:abc/pub.leaflet.document/3abc", data: {...} },
5151+ * { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
5252+ * { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
5353+ * ];
5454+ * const deduped = deduplicateByUri(docs);
5555+ * // Returns: [
5656+ * // { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
5757+ * // { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
5858+ * // ]
5959+ */
6060+export function deduplicateByUri<T extends { uri: string }>(records: T[]): T[] {
6161+ const recordsByKey = new Map<string, T>();
6262+6363+ for (const record of records) {
6464+ const key = getRecordIdentityKey(record.uri);
6565+ if (!key) {
6666+ // Invalid URI, keep the record as-is
6767+ continue;
6868+ }
6969+7070+ const existing = recordsByKey.get(key);
7171+ if (!existing) {
7272+ recordsByKey.set(key, record);
7373+ } else {
7474+ // Prefer site.standard records over pub.leaflet records
7575+ if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.uri)) {
7676+ recordsByKey.set(key, record);
7777+ }
7878+ // If both are same namespace or existing is already site.standard, keep existing
7979+ }
8080+ }
8181+8282+ return Array.from(recordsByKey.values());
8383+}
8484+8585+/**
8686+ * Deduplicates records while preserving the original order based on the first
8787+ * occurrence of each unique record.
8888+ *
8989+ * Same deduplication logic as deduplicateByUri, but maintains insertion order.
9090+ *
9191+ * @param records - Array of records with a `uri` property
9292+ * @returns Deduplicated array in original order, preferring site.standard records
9393+ */
9494+export function deduplicateByUriOrdered<T extends { uri: string }>(
9595+ records: T[]
9696+): T[] {
9797+ const recordsByKey = new Map<string, { record: T; index: number }>();
9898+9999+ for (let i = 0; i < records.length; i++) {
100100+ const record = records[i];
101101+ const key = getRecordIdentityKey(record.uri);
102102+ if (!key) {
103103+ continue;
104104+ }
105105+106106+ const existing = recordsByKey.get(key);
107107+ if (!existing) {
108108+ recordsByKey.set(key, { record, index: i });
109109+ } else {
110110+ // Prefer site.standard records over pub.leaflet records
111111+ if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.record.uri)) {
112112+ // Replace with site.standard but keep original position
113113+ recordsByKey.set(key, { record, index: existing.index });
114114+ }
115115+ }
116116+ }
117117+118118+ // Sort by original index to maintain order
119119+ return Array.from(recordsByKey.values())
120120+ .sort((a, b) => a.index - b.index)
121121+ .map((entry) => entry.record);
122122+}
+10-2
src/utils/deleteBlock.ts
···44import { scanIndex } from "src/replicache/utils";
55import { getBlocksWithType } from "src/hooks/queries/useBlocks";
66import { focusBlock } from "src/utils/focusBlock";
77+import { UndoManager } from "src/undoManager";
7889export async function deleteBlock(
910 entities: string[],
1011 rep: Replicache<ReplicacheMutators>,
1212+ undoManager?: UndoManager,
1113) {
1214 // get what pagess we need to close as a result of deleting this block
1315 let pagesToClose = [] as string[];
···3234 }
3335 }
34363535- // the next and previous blocks in the block list
3636- // if the focused thing is a page and not a block, return
3737+ // figure out what to focus
3738 let focusedBlock = useUIState.getState().focusedEntity;
3839 let parent =
3940 focusedBlock?.entityType === "page"
···4445 let parentType = await rep?.query((tx) =>
4546 scanIndex(tx).eav(parent, "page/type"),
4647 );
4848+ // if the page is a canvas, focus the page
4749 if (parentType[0]?.data.value === "canvas") {
4850 useUIState
4951 .getState()
5052 .setFocusedBlock({ entityType: "page", entityID: parent });
5153 useUIState.getState().setSelectedBlocks([]);
5254 } else {
5555+ // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block)
5356 let siblings =
5457 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
5558···105108 }
106109 }
107110111111+ // close the pages
108112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113113+ undoManager && undoManager.startGroup();
114114+115115+ // delete the blocks
109116 await Promise.all(
110117 entities.map((entity) =>
111118 rep?.mutate.removeBlock({
···113120 }),
114121 ),
115122 );
123123+ undoManager && undoManager.endGroup();
116124}
+3-1
src/utils/focusBlock.ts
···4848 }
49495050 if (pos?.offset !== undefined) {
5151- el?.focus();
5151+ // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page.
5252+ // idk how to fix so i'm giving up -- celine
5353+ // el?.focus();
5254 requestAnimationFrame(() => {
5355 el?.setSelectionRange(pos.offset, pos.offset);
5456 });