···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
+51-109
actions/publishToPublication.ts
···903903 const mentionedDids = new Set<string>();
904904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
905905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
906906- const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
907906908907 // Extract pages from either format
909908 let pages: PubLeafletContent.Main["pages"] | undefined;
···918917919918 if (!pages) return;
920919921921- // Helper to extract blocks from all pages (both linear and canvas)
922922- function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
923923- const blocks: (
924924- | PubLeafletPagesLinearDocument.Block["block"]
925925- | PubLeafletPagesCanvas.Block["block"]
926926- )[] = [];
927927- for (const page of pages) {
928928- if (page.$type === "pub.leaflet.pages.linearDocument") {
929929- const linearPage = page as PubLeafletPagesLinearDocument.Main;
930930- for (const blockWrapper of linearPage.blocks) {
931931- blocks.push(blockWrapper.block);
932932- }
933933- } else if (page.$type === "pub.leaflet.pages.canvas") {
934934- const canvasPage = page as PubLeafletPagesCanvas.Main;
935935- for (const blockWrapper of canvasPage.blocks) {
936936- blocks.push(blockWrapper.block);
937937- }
938938- }
939939- }
940940- return blocks;
941941- }
942942-943943- const allBlocks = getAllBlocks(pages);
944944-945945- // Extract mentions from all text blocks and embedded Bluesky posts
946946- for (const block of allBlocks) {
947947- // Check for embedded Bluesky posts
948948- if (PubLeafletBlocksBskyPost.isMain(block)) {
949949- const bskyPostUri = block.postRef.uri;
950950- // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
951951- const postAuthorDid = new AtUri(bskyPostUri).host;
952952- if (postAuthorDid !== authorDid) {
953953- embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
954954- }
955955- }
956956-957957- // Check for text blocks with mentions
958958- if (block.$type === "pub.leaflet.blocks.text") {
959959- const textBlock = block as PubLeafletBlocksText.Main;
960960- if (textBlock.facets) {
961961- for (const facet of textBlock.facets) {
962962- for (const feature of facet.features) {
963963- // Check for DID mentions
964964- if (PubLeafletRichtextFacet.isDidMention(feature)) {
965965- if (feature.did !== authorDid) {
966966- mentionedDids.add(feature.did);
967967- }
968968- }
969969- // Check for AT URI mentions (publications and documents)
970970- if (PubLeafletRichtextFacet.isAtMention(feature)) {
971971- 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);
972940973973- if (isPublicationCollection(uri.collection)) {
974974- // Get the publication owner's DID
975975- const { data: publication } = await supabaseServerClient
976976- .from("publications")
977977- .select("identity_did")
978978- .eq("uri", feature.atURI)
979979- .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();
980948981981- if (publication && publication.identity_did !== authorDid) {
982982- mentionedPublications.set(
983983- publication.identity_did,
984984- feature.atURI,
985985- );
986986- }
987987- } else if (isDocumentCollection(uri.collection)) {
988988- // Get the document owner's DID
989989- const { data: document } = await supabaseServerClient
990990- .from("documents")
991991- .select("uri, data")
992992- .eq("uri", feature.atURI)
993993- .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();
994962995995- if (document) {
996996- const normalizedMentionedDoc = normalizeDocumentRecord(
997997- document.data,
998998- );
999999- // Get the author from the document URI (the DID is the host part)
10001000- const mentionedUri = new AtUri(feature.atURI);
10011001- const docAuthor = mentionedUri.host;
10021002- if (normalizedMentionedDoc && docAuthor !== authorDid) {
10031003- 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+ }
1004974 }
1005975 }
1006976 }
···10561026 };
10571027 await supabaseServerClient.from("notifications").insert(notification);
10581028 await pingIdentityToUpdateNotification(recipientDid);
10591059- }
10601060-10611061- // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
10621062- if (embeddedBskyPosts.size > 0) {
10631063- // Check which of the Bluesky post authors have Leaflet accounts
10641064- const { data: identities } = await supabaseServerClient
10651065- .from("identities")
10661066- .select("atp_did")
10671067- .in("atp_did", Array.from(embeddedBskyPosts.keys()));
10681068-10691069- const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
10701070-10711071- for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
10721072- // Only notify if the post author has a Leaflet account
10731073- if (leafletUserDids.has(postAuthorDid)) {
10741074- const notification: Notification = {
10751075- id: v7(),
10761076- recipient: postAuthorDid,
10771077- data: {
10781078- type: "bsky_post_embed",
10791079- document_uri: documentUri,
10801080- bsky_post_uri: bskyPostUri,
10811081- },
10821082- };
10831083- await supabaseServerClient.from("notifications").insert(notification);
10841084- await pingIdentityToUpdateNotification(postAuthorDid);
10851085- }
10861086- }
10871029 }
10881030}
+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
···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 });