···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
56- no build errors!!!
+2-5
actions/getIdentityData.ts
···33import { cookies } from "next/headers";
44import { supabaseServerClient } from "supabase/serverClient";
55import { cache } from "react";
66-import { deduplicateByUri } from "src/utils/deduplicateRecords";
76export const getIdentityData = cache(uncachedGetIdentityData);
87export async function uncachedGetIdentityData() {
98 let cookieStore = await cookies();
···4544 if (!auth_res?.data?.identities) return null;
4645 if (auth_res.data.identities.atp_did) {
4746 //I should create a relationship table so I can do this in the above query
4848- let { data: rawPublications } = await supabaseServerClient
4747+ let { data: publications } = await supabaseServerClient
4948 .from("publications")
5049 .select("*")
5150 .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 || []);
5451 return {
5552 ...auth_res.data.identities,
5656- publications,
5353+ publications: publications || [],
5754 };
5855 }
5956
+54-115
actions/publishToPublication.ts
···199199 }
200200201201 // Determine the collection to use - preserve existing schema if updating
202202- const existingCollection = existingDocUri
203203- ? new AtUri(existingDocUri).collection
204204- : undefined;
202202+ const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined;
205203 const documentType = getDocumentType(existingCollection);
206204207205 // Build the pages array (used by both formats)
···230228 if (documentType === "site.standard.document") {
231229 // site.standard.document format
232230 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
233233- const siteUri =
234234- publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
231231+ const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
235232236233 record = {
237234 $type: "site.standard.document",
238235 title: title || "Untitled",
239236 site: siteUri,
240240- path: "/" + rkey,
237237+ path: rkey,
241238 publishedAt:
242239 publishedAt || existingRecord.publishedAt || new Date().toISOString(),
243240 ...(description && { description }),
···906903 const mentionedDids = new Set<string>();
907904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
908905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
909909- const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
910906911907 // Extract pages from either format
912908 let pages: PubLeafletContent.Main["pages"] | undefined;
···921917922918 if (!pages) return;
923919924924- // 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- }
945945-946946- const allBlocks = getAllBlocks(pages);
947947-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- }
959959-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);
920920+ // 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);
975940976976- 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();
941941+ 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();
983948984984- 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();
949949+ 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();
997962998998- 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);
963963+ 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+ }
1007974 }
1008975 }
1009976 }
···10591026 };
10601027 await supabaseServerClient.from("notifications").insert(notification);
10611028 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- }
10901029 }
10911030}
+1-5
app/(home-pages)/discover/getPublications.ts
···55 normalizePublicationRow,
66 hasValidPublication,
77} from "src/utils/normalizeRecords";
88-import { deduplicateByUri } from "src/utils/deduplicateRecords";
98109export type Cursor = {
1110 indexed_at?: string;
···4342 return { publications: [], nextCursor: null };
4443 }
45444646- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
4747- const dedupedPublications = deduplicateByUri(publications || []);
4848-4945 // Filter out publications without documents
5050- const allPubs = dedupedPublications.filter(
4646+ const allPubs = (publications || []).filter(
5147 (pub) => pub.documents_in_publications.length > 0,
5248 );
5349
···1414 */
15151616import type * as PubLeafletDocument from "../api/types/pub/leaflet/document";
1717-import * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
1717+import type * 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-// 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-};
3434+export type NormalizedPublication = SiteStandardPublication.Record;
48354936/**
5037 * Checks if the record is a pub.leaflet.document
···223210): NormalizedPublication | null {
224211 if (!record || typeof record !== "object") return null;
225212226226- // Pass through site.standard records directly, but validate the theme
213213+ // Pass through site.standard records directly
227214 if (isStandardPublication(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- };
215215+ return record;
236216 }
237217238218 if (isLeafletPublication(record)) {
···245225246226 const basicTheme = leafletThemeToBasicTheme(record.theme);
247227248248- // 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-263228 // Convert preferences to site.standard format (strip/replace $type)
264229 const preferences: SiteStandardPublication.Preferences | undefined =
265230 record.preferences
···278243 description: record.description,
279244 icon: record.icon,
280245 basicTheme,
281281- theme,
246246+ theme: record.theme,
282247 preferences,
283248 };
284249 }
···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- }
7770 }
7871 if (!ignoreUndo)
7972 undoManager.add({
+10-31
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";
8798export type MutationContext = {
109 permission_token_id: string;
···308307 { blockEntity: string } | { blockEntity: string }[]
309308> = async (args, ctx) => {
310309 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;
311315 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
312316 await ctx.runOnServer(async ({ supabase }) => {
313317 if (image) {
···423427 },
424428 });
425429};
426426-const moveBlockDown: Mutation<{
427427- entityID: string;
428428- parent: string;
429429- permission_set?: string;
430430-}> = async (args, ctx) => {
430430+const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async (
431431+ args,
432432+ ctx,
433433+) => {
431434 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
432435 (a, b) => (a.data.position > b.data.position ? 1 : -1),
433436 );
434437 let index = children.findIndex((f) => f.data.value === args.entityID);
435438 if (index === -1) return;
436439 let next = children[index + 1];
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- }
440440+ if (!next) return;
462441 await ctx.retractFact(children[index].id);
463442 await ctx.assertFact({
464443 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-}
+2-10
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";
8798export async function deleteBlock(
109 entities: string[],
1110 rep: Replicache<ReplicacheMutators>,
1212- undoManager?: UndoManager,
1311) {
1412 // get what pagess we need to close as a result of deleting this block
1513 let pagesToClose = [] as string[];
···3432 }
3533 }
36343737- // figure out what to focus
3535+ // the next and previous blocks in the block list
3636+ // if the focused thing is a page and not a block, return
3837 let focusedBlock = useUIState.getState().focusedEntity;
3938 let parent =
4039 focusedBlock?.entityType === "page"
···4544 let parentType = await rep?.query((tx) =>
4645 scanIndex(tx).eav(parent, "page/type"),
4746 );
4848- // if the page is a canvas, focus the page
4947 if (parentType[0]?.data.value === "canvas") {
5048 useUIState
5149 .getState()
5250 .setFocusedBlock({ entityType: "page", entityID: parent });
5351 useUIState.getState().setSelectedBlocks([]);
5452 } else {
5555- // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block)
5653 let siblings =
5754 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
5855···108105 }
109106 }
110107111111- // close the pages
112108 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113113- undoManager && undoManager.startGroup();
114114-115115- // delete the blocks
116109 await Promise.all(
117110 entities.map((entity) =>
118111 rep?.mutate.removeBlock({
···120113 }),
121114 ),
122115 );
123123- undoManager && undoManager.endGroup();
124116}
+1-3
src/utils/focusBlock.ts
···4848 }
49495050 if (pos?.offset !== undefined) {
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();
5151+ el?.focus();
5452 requestAnimationFrame(() => {
5553 el?.setSelectionRange(pos.offset, pos.offset);
5654 });