a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

optimize homepage leaflets query

Jared Pereira (May 22, 2026, 4:45 PM EDT) e25645fe 74b92987

+257 -282
+4 -2
actions/getIdentityData.ts
··· 33 33 permission_tokens!inner( 34 34 id, 35 35 root_entity, 36 + title, 37 + description, 36 38 permission_token_rights(*), 37 - leaflets_to_documents(*, documents(*)), 38 - leaflets_in_publications(*, publications(*), documents(*)) 39 + leaflets_to_documents(*), 40 + leaflets_in_publications(*, publications(*)) 39 41 ) 40 42 ), 41 43 user_subscriptions(plan, status, current_period_end),
+12 -59
app/(home-pages)/(writer)/home/HomeLayout.tsx
··· 2 2 3 3 import { getHomeDocs } from "./storage"; 4 4 import useSWR from "swr"; 5 - import { 6 - Fact, 7 - PermissionToken, 8 - ReplicacheProvider, 9 - useEntity, 10 - } from "src/replicache"; 5 + import { PermissionToken, useEntity } from "src/replicache"; 11 6 import { LeafletListItem } from "./LeafletList/LeafletListItem"; 12 7 import { useIdentityData } from "components/IdentityProvider"; 13 - import type { Attribute } from "src/replicache/attributes"; 14 - import { callRPC } from "app/api/rpc/client"; 15 8 import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 16 9 import { 17 10 type DashboardState, ··· 24 17 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 25 18 import { HomeEmptyState } from "./HomeEmpty/HomeEmpty"; 26 19 import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; 20 + import { LeafletCardReplicache } from "./LeafletList/LeafletCardReplicache"; 27 21 28 22 export type Leaflet = { 29 23 added_at: string; 30 24 archived?: boolean | null; 31 25 token: PermissionToken & { 26 + title?: string | null; 27 + description?: string | null; 32 28 leaflets_in_publications?: Exclude< 33 29 GetLeafletDataReturnType["result"]["data"], 34 30 null ··· 43 39 export const HomeContent = (props: { 44 40 entityID: string | null; 45 41 titles: { [root_entity: string]: string }; 46 - initialFacts: { 47 - [root_entity: string]: Fact<Attribute>[]; 48 - }; 49 42 }) => { 50 43 let hasBackgroundImage = !!useEntity( 51 44 props.entityID, ··· 92 85 > 93 86 <HomeLeafletList 94 87 titles={props.titles} 95 - initialFacts={props.initialFacts} 96 88 searchValue={debouncedSearchValue} 97 89 /> 98 90 </DashboardPageLayout> ··· 101 93 102 94 export function HomeLeafletList(props: { 103 95 titles: { [root_entity: string]: string }; 104 - initialFacts: { 105 - [root_entity: string]: Fact<Attribute>[]; 106 - }; 107 96 searchValue: string; 108 97 }) { 109 98 let { identity } = useIdentityData(); 110 - let { data: initialFacts } = useSWR( 111 - "home-leaflet-data", 112 - async () => { 113 - if (identity) { 114 - let { result } = await callRPC("getFactsFromHomeLeaflets", { 115 - tokens: identity.permission_token_on_homepage.map( 116 - (ptrh) => ptrh.permission_tokens.root_entity, 117 - ), 118 - }); 119 - let titles = { 120 - ...result.titles, 121 - ...identity.permission_token_on_homepage.reduce( 122 - (acc, tok) => { 123 - let title = 124 - tok.permission_tokens.leaflets_in_publications[0]?.title || 125 - tok.permission_tokens.leaflets_to_documents[0]?.title; 126 - if (title) acc[tok.permission_tokens.root_entity] = title; 127 - return acc; 128 - }, 129 - {} as { [k: string]: string }, 130 - ), 131 - }; 132 - return { ...result, titles }; 133 - } 134 - }, 135 - { 136 - fallbackData: { facts: props.initialFacts, titles: props.titles }, 137 - revalidateOnFocus: false, 138 - revalidateOnReconnect: false, 139 - }, 140 - ); 141 99 142 100 let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 143 101 fallbackData: [], ··· 161 119 defaultDisplay="grid" 162 120 searchValue={props.searchValue} 163 121 leaflets={leaflets} 164 - titles={initialFacts?.titles || {}} 165 - initialFacts={initialFacts?.facts || {}} 122 + titles={props.titles} 166 123 showPreview 167 124 /> 168 125 <div className="spacer sm:block hidden h-4 w-full bg-transparent shrink-0 " /> ··· 174 131 leaflets: Leaflet[]; 175 132 titles: { [root_entity: string]: string }; 176 133 defaultDisplay: Exclude<DashboardState["display"], undefined>; 177 - initialFacts: { 178 - [root_entity: string]: Fact<Attribute>[]; 179 - }; 180 134 searchValue: string; 181 135 showPreview?: boolean; 182 136 }) { ··· 199 153 ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2"} `} 200 154 > 201 155 {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 202 - <ReplicacheProvider 203 - disablePull 204 - initialFactsOnly={!!identity} 156 + <LeafletCardReplicache 205 157 key={leaflet.id} 206 - rootEntity={leaflet.root_entity} 207 - token={leaflet} 208 - name={leaflet.root_entity} 209 - initialFacts={props.initialFacts?.[leaflet.root_entity] || []} 158 + leaflet={leaflet} 159 + loggedIn={!!identity} 160 + eagerLoadFacts={index < 12} 210 161 > 211 162 <StaticLeafletDataContext 212 163 value={{ 213 164 ...leaflet, 165 + title: leaflet.title ?? null, 166 + description: leaflet.description ?? null, 214 167 leaflets_in_publications: leaflet.leaflets_in_publications || [], 215 168 leaflets_to_documents: leaflet.leaflets_to_documents || [], 216 169 publication_pages: [], ··· 233 186 } 234 187 /> 235 188 </StaticLeafletDataContext> 236 - </ReplicacheProvider> 189 + </LeafletCardReplicache> 237 190 ))} 238 191 </div> 239 192 );
+25
app/(home-pages)/(writer)/home/LeafletList/HomeLeafletFactsProvider.tsx
··· 1 + "use client"; 2 + 3 + import useSWR from "swr"; 4 + import { create, windowScheduler } from "@yornaath/batshit"; 5 + import { callRPC } from "app/api/rpc/client"; 6 + import type { Fact } from "src/replicache"; 7 + import type { Attribute } from "src/replicache/attributes"; 8 + 9 + const leafletFactsBatcher = create({ 10 + fetcher: async (roots: string[]) => { 11 + const response = await callRPC("getFactsForRoots", { roots }); 12 + return response.result.facts as { [root: string]: Fact<Attribute>[] }; 13 + }, 14 + resolver: (results, root: string) => results[root] ?? [], 15 + scheduler: windowScheduler(10), 16 + }); 17 + 18 + export function useLeafletFacts(root: string, enabled: boolean = true) { 19 + const { data, isLoading } = useSWR( 20 + enabled ? `leaflet-facts:${root}` : null, 21 + () => leafletFactsBatcher.fetch(root), 22 + { revalidateOnFocus: false, revalidateOnReconnect: false }, 23 + ); 24 + return { facts: data ?? null, isLoading }; 25 + }
+50
app/(home-pages)/(writer)/home/LeafletList/LeafletCardReplicache.tsx
··· 1 + "use client"; 2 + 3 + import { createContext, useCallback, useContext, useMemo, useState } from "react"; 4 + import { ReplicacheProvider, type PermissionToken } from "src/replicache"; 5 + import { useLeafletFacts } from "./HomeLeafletFactsProvider"; 6 + 7 + const CardVisibilityContext = createContext<{ 8 + notifyVisible: () => void; 9 + } | null>(null); 10 + 11 + export function useReportCardVisible() { 12 + return useContext(CardVisibilityContext); 13 + } 14 + 15 + export function LeafletCardReplicache(props: { 16 + leaflet: PermissionToken; 17 + loggedIn: boolean; 18 + eagerLoadFacts: boolean; 19 + children: React.ReactNode; 20 + }) { 21 + const [hasBeenVisible, setHasBeenVisible] = useState(props.eagerLoadFacts); 22 + const { facts } = useLeafletFacts( 23 + props.leaflet.root_entity, 24 + hasBeenVisible, 25 + ); 26 + 27 + const notifyVisible = useCallback(() => { 28 + setHasBeenVisible((v) => v || true); 29 + }, []); 30 + const contextValue = useMemo(() => ({ notifyVisible }), [notifyVisible]); 31 + 32 + const initialFacts = facts ?? []; 33 + const providerKey = `${props.leaflet.id}:${facts ? "loaded" : "loading"}`; 34 + 35 + return ( 36 + <CardVisibilityContext.Provider value={contextValue}> 37 + <ReplicacheProvider 38 + disablePull 39 + initialFactsOnly={props.loggedIn} 40 + key={providerKey} 41 + rootEntity={props.leaflet.root_entity} 42 + token={props.leaflet} 43 + name={props.leaflet.root_entity} 44 + initialFacts={initialFacts} 45 + > 46 + {props.children} 47 + </ReplicacheProvider> 48 + </CardVisibilityContext.Provider> 49 + ); 50 + }
+1 -8
app/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx
··· 1 1 "use client"; 2 - import { useEntity } from "src/replicache"; 3 2 import { LeafletOptions } from "./LeafletOptions"; 4 3 import { timeAgo } from "src/utils/timeAgo"; 5 - import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 6 4 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 5 8 6 export const LeafletInfo = (props: { ··· 19 17 ? timeAgo(pubStatus.publishedAt) 20 18 : ""; 21 19 22 - // Look up root page first, like UpdateLeafletTitle does 23 - let firstPage = useEntity(pubStatus?.leafletId ?? "", "root/page")[0]; 24 - let entityID = firstPage?.data.value || pubStatus?.leafletId || ""; 25 - let titleFromDb = usePageTitle(entityID); 26 - 27 - let title = props.title ?? titleFromDb ?? "Untitled"; 20 + let title = props.title ?? "Untitled"; 28 21 29 22 return ( 30 23 <div
+4 -1
app/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx
··· 5 5 import { SpeedyLink } from "components/SpeedyLink"; 6 6 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 7 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 + import { useReportCardVisible } from "./LeafletCardReplicache"; 8 9 9 10 export const LeafletListItem = (props: { 10 11 archived?: boolean | null; ··· 18 19 }) => { 19 20 const cardBorderHidden = useCardBorderHidden(); 20 21 const pubStatus = useLeafletPublicationStatus(); 22 + const visibilityReporter = useReportCardVisible(); 21 23 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 22 24 let previewRef = useRef<HTMLDivElement | null>(null); 23 25 ··· 28 30 entries.forEach((entry) => { 29 31 if (entry.isIntersecting) { 30 32 setIsOnScreen(true); 33 + visibilityReporter?.notifyVisible(); 31 34 } else { 32 35 setIsOnScreen(false); 33 36 } ··· 37 40 ); 38 41 observer.observe(previewRef.current); 39 42 return () => observer.disconnect(); 40 - }, [previewRef]); 43 + }, [previewRef, visibilityReporter]); 41 44 42 45 const tokenId = pubStatus?.shareLink ?? ""; 43 46
+14 -30
app/(home-pages)/(writer)/home/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 - import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 2 5 3 import { HomeContent } from "./HomeLayout"; 6 4 7 5 export default async function Home() { 8 6 let auth_res = await getIdentityData(); 9 7 10 - let [allLeafletFacts] = await Promise.all([ 11 - auth_res 12 - ? getFactsFromHomeLeaflets.handler( 13 - { 14 - tokens: auth_res.permission_token_on_homepage.map( 15 - (r) => r.permission_tokens.root_entity, 16 - ), 17 - }, 18 - { supabase: supabaseServerClient }, 19 - ) 20 - : undefined, 21 - ]); 22 - 23 - let home_docs_initialFacts = allLeafletFacts?.result || {}; 8 + let titles = 9 + auth_res?.permission_token_on_homepage.reduce( 10 + (acc, tok) => { 11 + let title = 12 + tok.permission_tokens.leaflets_in_publications[0]?.title || 13 + tok.permission_tokens.leaflets_to_documents[0]?.title || 14 + tok.permission_tokens.title || 15 + undefined; 16 + if (title) acc[tok.permission_tokens.root_entity] = title; 17 + return acc; 18 + }, 19 + {} as { [k: string]: string }, 20 + ) ?? {}; 24 21 25 22 return ( 26 23 <HomeContent 27 - titles={{ 28 - ...home_docs_initialFacts.titles, 29 - ...auth_res?.permission_token_on_homepage.reduce( 30 - (acc, tok) => { 31 - let title = 32 - tok.permission_tokens.leaflets_in_publications[0]?.title || 33 - tok.permission_tokens.leaflets_to_documents[0]?.title; 34 - if (title) acc[tok.permission_tokens.root_entity] = title; 35 - return acc; 36 - }, 37 - {} as { [k: string]: string }, 38 - ), 39 - }} 24 + titles={titles} 40 25 entityID={auth_res?.home_leaflet?.root_entity || null} 41 - initialFacts={home_docs_initialFacts.facts || {}} 42 26 /> 43 27 ); 44 28 }
+2 -40
app/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx
··· 2 2 import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 3 3 import { useState } from "react"; 4 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 - import { Fact, PermissionToken } from "src/replicache"; 6 - import { Attribute } from "src/replicache/attributes"; 7 - import { callRPC } from "app/api/rpc/client"; 5 + import { PermissionToken } from "src/replicache"; 8 6 import { useIdentityData } from "components/IdentityProvider"; 9 - import useSWR from "swr"; 10 7 import { Leaflet, LeafletList } from "../home/HomeLayout"; 11 8 import { CreateNewLeafletButton } from "../home/Actions/CreateNewButton"; 12 9 13 10 export const LooseleafsContent = (props: { 14 11 entityID: string | null; 15 12 titles: { [root_entity: string]: string }; 16 - initialFacts: { 17 - [root_entity: string]: Fact<Attribute>[]; 18 - }; 19 13 }) => { 20 14 let [searchValue, setSearchValue] = useState(""); 21 15 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 37 31 > 38 32 <LooseleafList 39 33 titles={props.titles} 40 - initialFacts={props.initialFacts} 41 34 searchValue={debouncedSearchValue} 42 35 /> 43 36 </DashboardPageLayout> ··· 46 39 47 40 export const LooseleafList = (props: { 48 41 titles: { [root_entity: string]: string }; 49 - initialFacts: { 50 - [root_entity: string]: Fact<Attribute>[]; 51 - }; 52 42 searchValue: string; 53 43 }) => { 54 44 let { identity } = useIdentityData(); 55 - let { data: initialFacts } = useSWR( 56 - "home-leaflet-data", 57 - async () => { 58 - if (identity) { 59 - let { result } = await callRPC("getFactsFromHomeLeaflets", { 60 - tokens: identity.permission_token_on_homepage.map( 61 - (ptrh) => ptrh.permission_tokens.root_entity, 62 - ), 63 - }); 64 - let titles = { 65 - ...result.titles, 66 - ...identity.permission_token_on_homepage.reduce( 67 - (acc, tok) => { 68 - let title = 69 - tok.permission_tokens.leaflets_in_publications[0]?.title || 70 - tok.permission_tokens.leaflets_to_documents[0]?.title; 71 - if (title) acc[tok.permission_tokens.root_entity] = title; 72 - return acc; 73 - }, 74 - {} as { [k: string]: string }, 75 - ), 76 - }; 77 - return { ...result, titles }; 78 - } 79 - }, 80 - { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 81 - ); 82 45 83 46 let leaflets: Leaflet[] = identity 84 47 ? identity.permission_token_on_homepage ··· 95 58 defaultDisplay="list" 96 59 searchValue={props.searchValue} 97 60 leaflets={leaflets} 98 - titles={initialFacts?.titles || {}} 99 - initialFacts={initialFacts?.facts || {}} 61 + titles={props.titles} 100 62 showPreview 101 63 /> 102 64 );
+14 -30
app/(home-pages)/(writer)/looseleafs/page.tsx
··· 1 1 import { getIdentityData } from "actions/getIdentityData"; 2 - import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 2 import { LooseleafsContent } from "./LooseleafsLayout"; 5 3 6 4 export default async function Home() { 7 5 let auth_res = await getIdentityData(); 8 6 9 - let [allLeafletFacts] = await Promise.all([ 10 - auth_res 11 - ? getFactsFromHomeLeaflets.handler( 12 - { 13 - tokens: auth_res.permission_token_on_homepage.map( 14 - (r) => r.permission_tokens.root_entity, 15 - ), 16 - }, 17 - { supabase: supabaseServerClient }, 18 - ) 19 - : undefined, 20 - ]); 21 - 22 - let home_docs_initialFacts = allLeafletFacts?.result || {}; 7 + let titles = 8 + auth_res?.permission_token_on_homepage.reduce( 9 + (acc, tok) => { 10 + let title = 11 + tok.permission_tokens.leaflets_in_publications[0]?.title || 12 + tok.permission_tokens.leaflets_to_documents[0]?.title || 13 + tok.permission_tokens.title || 14 + undefined; 15 + if (title) acc[tok.permission_tokens.root_entity] = title; 16 + return acc; 17 + }, 18 + {} as { [k: string]: string }, 19 + ) ?? {}; 23 20 24 21 return ( 25 22 <LooseleafsContent 26 23 entityID={auth_res?.home_leaflet?.root_entity || null} 27 - titles={{ 28 - ...home_docs_initialFacts.titles, 29 - ...auth_res?.permission_token_on_homepage.reduce( 30 - (acc, tok) => { 31 - let title = 32 - tok.permission_tokens.leaflets_in_publications[0]?.title || 33 - tok.permission_tokens.leaflets_to_documents[0]?.title; 34 - if (title) acc[tok.permission_tokens.root_entity] = title; 35 - return acc; 36 - }, 37 - {} as { [k: string]: string }, 38 - ), 39 - }} 40 - initialFacts={home_docs_initialFacts.facts || {}} 24 + titles={titles} 41 25 /> 42 26 ); 43 27 }
+2
app/[leaflet_id]/actions/HomeButton.tsx
··· 52 52 archived: null, 53 53 permission_tokens: { 54 54 ...permission_token, 55 + title: null, 56 + description: null, 55 57 leaflets_to_documents: [], 56 58 leaflets_in_publications: [], 57 59 },
+23
app/api/rpc/[command]/getFactsForRoots.ts
··· 1 + import { z } from "zod"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { makeRoute } from "../lib"; 5 + import type { Env } from "./route"; 6 + 7 + export const getFactsForRoots = makeRoute({ 8 + route: "getFactsForRoots", 9 + input: z.object({ 10 + roots: z.array(z.string()), 11 + }), 12 + handler: async ({ roots }, { supabase }: Pick<Env, "supabase">) => { 13 + const { data } = await supabase.rpc("get_facts_for_roots", { 14 + max_depth: 3, 15 + roots, 16 + }); 17 + const facts: { [root: string]: Fact<Attribute>[] } = {}; 18 + for (const f of data ?? []) { 19 + (facts[f.root_id] ??= []).push(f as unknown as Fact<Attribute>); 20 + } 21 + return { result: { facts } }; 22 + }, 23 + });
-91
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 1 - import { z } from "zod"; 2 - import type { Fact } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { makeRoute } from "../lib"; 5 - import type { Env } from "./route"; 6 - import { scanIndexLocal } from "src/replicache/utils"; 7 - import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 - import { applyUpdate, Doc } from "yjs"; 10 - 11 - export const getFactsFromHomeLeaflets = makeRoute({ 12 - route: "getFactsFromHomeLeaflets", 13 - input: z.object({ 14 - tokens: z.array(z.string()), 15 - }), 16 - handler: async ({ tokens }, { supabase }: Pick<Env, "supabase">) => { 17 - let all_facts = await supabase.rpc("get_facts_for_roots", { 18 - max_depth: 3, 19 - roots: tokens, 20 - }); 21 - 22 - if (all_facts.data) { 23 - let titles = {} as { [key: string]: string }; 24 - 25 - let facts = all_facts.data.reduce( 26 - (acc, fact) => { 27 - if (!acc[fact.root_id]) acc[fact.root_id] = []; 28 - acc[fact.root_id].push(fact as unknown as Fact<Attribute>); 29 - return acc; 30 - }, 31 - {} as { [key: string]: Fact<Attribute>[] }, 32 - ); 33 - for (let token of tokens) { 34 - let scan = scanIndexLocal(facts[token] || []); 35 - let [root] = scan.eav(token, "root/page"); 36 - let rootEntity = root?.data.value || token; 37 - 38 - // Check page type to determine which blocks to look up 39 - let [pageType] = scan.eav(rootEntity, "page/type"); 40 - let isCanvas = pageType?.data.value === "canvas"; 41 - 42 - // Get blocks and sort by position 43 - let rawBlocks = isCanvas 44 - ? scan.eav(rootEntity, "canvas/block").sort((a, b) => { 45 - if (a.data.position.y === b.data.position.y) 46 - return a.data.position.x - b.data.position.x; 47 - return a.data.position.y - b.data.position.y; 48 - }) 49 - : scan.eav(rootEntity, "card/block").sort((a, b) => { 50 - if (a.data.position === b.data.position) 51 - return a.id > b.id ? 1 : -1; 52 - return a.data.position > b.data.position ? 1 : -1; 53 - }); 54 - 55 - // Map to get type and filter for text/heading 56 - let blocks = rawBlocks 57 - .map((b) => { 58 - let type = scan.eav(b.data.value, "block/type")[0]; 59 - if ( 60 - !type || 61 - (type.data.value !== "text" && type.data.value !== "heading") 62 - ) 63 - return null; 64 - return b.data; 65 - }) 66 - .filter((b): b is NonNullable<typeof b> => b !== null); 67 - 68 - let title = blocks[0]; 69 - 70 - if (!title) titles[token] = "Untitled"; 71 - else { 72 - let [content] = scan.eav(title.value, "block/text"); 73 - if (!content) titles[token] = "Untitled"; 74 - else { 75 - let doc = new Doc(); 76 - const update = base64.toByteArray(content.data.value); 77 - applyUpdate(doc, update); 78 - let nodes = doc.getXmlElement("prosemirror").toArray(); 79 - let stringValue = YJSFragmentToString(nodes[0]); 80 - titles[token] = stringValue; 81 - } 82 - } 83 - } 84 - return { 85 - result: { facts, titles }, 86 - }; 87 - } 88 - 89 - return { result: {} }; 90 - }, 91 - });
-16
app/api/rpc/[command]/get_publication_data.ts
··· 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 4 import { AtUri } from "@atproto/syntax"; 5 - import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 6 5 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 6 import { ids } from "lexicons/api/lexicons"; 8 7 ··· 69 68 .limit(1) 70 69 .single(); 71 70 72 - let leaflet_data = await getFactsFromHomeLeaflets.handler( 73 - { 74 - tokens: [ 75 - ...(publication?.leaflets_in_publications.map( 76 - (l) => l.permission_tokens?.root_entity!, 77 - ) || []), 78 - ...(publication?.publication_pages.map( 79 - (p) => p.permission_tokens?.root_entity!, 80 - ) || []), 81 - ].filter(Boolean), 82 - }, 83 - { supabase }, 84 - ); 85 - 86 71 // Pre-normalize documents from documents_in_publications 87 72 const documents = (publication?.documents_in_publications || []) 88 73 .map((dip) => { ··· 140 125 documents, 141 126 drafts, 142 127 pages, 143 - leaflet_data: leaflet_data.result, 144 128 }, 145 129 }; 146 130 },
+2 -2
app/api/rpc/[command]/route.ts
··· 3 3 import { createClient } from "@supabase/supabase-js"; 4 4 import { Database } from "supabase/database.types"; 5 5 import { pull } from "./pull"; 6 - import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 6 + import { getFactsForRoots } from "./getFactsForRoots"; 7 7 import { Vercel } from "@vercel/sdk"; 8 8 import { 9 9 get_domain_status, ··· 41 41 let Routes = [ 42 42 push, 43 43 pull, 44 - getFactsFromHomeLeaflets, 44 + getFactsForRoots, 45 45 get_domain_status, 46 46 get_leaflet_subdomain_status, 47 47 get_leaflet_data,
+1 -2
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 15 15 // Normalize the publication record - skip rendering if unrecognized format 16 16 const normalizedPubRecord = useNormalizedPublicationRecord(); 17 17 if (!pub_data?.publication) return null; 18 - const { drafts, leaflet_data } = pub_data; 18 + const { drafts } = pub_data; 19 19 const { leaflets_in_publications, ...publication } = pub_data.publication; 20 20 21 21 if (!normalizedPubRecord) return null; ··· 41 41 ], 42 42 }, 43 43 }))} 44 - initialFacts={leaflet_data.facts || {}} 45 44 titles={{ 46 45 ...drafts.reduce( 47 46 (acc, draft) => {
+17 -1
components/utils/UpdateLeafletTitle.tsx
··· 1 1 "use client"; 2 2 3 - import { useEffect, useState } from "react"; 3 + import { useEffect, useRef, useState } from "react"; 4 4 import { 5 5 useBlocks, 6 6 useCanvasBlocksWithType, ··· 13 13 import { focusBlock } from "src/utils/focusBlock"; 14 14 import { useIsMobile } from "src/hooks/isMobile"; 15 15 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 16 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 16 17 17 18 export function UpdateLeafletTitle(props: { entityID: string }) { 18 19 let { data: pub } = useLeafletPublicationData(); ··· 24 25 ); 25 26 let firstBlock = blocks[0]; 26 27 let title = usePageTitle(entityID); 28 + let { rep, permission_token } = useReplicache(); 29 + let hasWrite = permission_token?.permission_token_rights?.some( 30 + (r) => r.write, 31 + ); 32 + let lastSentTitleRef = useRef<string | null>(null); 33 + useDebouncedEffect( 34 + () => { 35 + if (!rep || !hasWrite || !title) return; 36 + if (lastSentTitleRef.current === title) return; 37 + lastSentTitleRef.current = title; 38 + rep.mutate.updateLeafletMetadata({ title }); 39 + }, 40 + 500, 41 + [rep, hasWrite, title], 42 + ); 27 43 useEffect(() => { 28 44 if (pub?.title) { 29 45 document.title = pub.title;
+27
src/replicache/mutations.ts
··· 720 720 }); 721 721 }; 722 722 723 + const updateLeafletMetadata: Mutation<{ 724 + title?: string; 725 + description?: string | null; 726 + }> = async (args, ctx) => { 727 + await ctx.runOnServer(async (serverCtx) => { 728 + const updates: { title?: string; description?: string | null } = {}; 729 + if (args.title !== undefined) updates.title = args.title; 730 + if (args.description !== undefined) updates.description = args.description; 731 + if (Object.keys(updates).length === 0) return; 732 + 733 + const { data: writerToken } = await serverCtx.supabase 734 + .from("permission_tokens") 735 + .select("root_entity, permission_token_rights(write)") 736 + .eq("id", ctx.permission_token_id) 737 + .single(); 738 + if (!writerToken) return; 739 + const hasWrite = writerToken.permission_token_rights.some((r) => r.write); 740 + if (!hasWrite) return; 741 + 742 + await serverCtx.supabase 743 + .from("permission_tokens") 744 + .update(updates) 745 + .eq("root_entity", writerToken.root_entity); 746 + }); 747 + }; 748 + 723 749 const createFootnote: Mutation<{ 724 750 footnoteEntityID: string; 725 751 blockID: string; ··· 774 800 addPollOption, 775 801 removePollOption, 776 802 updatePublicationDraft, 803 + updateLeafletMetadata, 777 804 createFootnote, 778 805 deleteFootnote, 779 806 };
+53
src/utils/extractTitleFromFacts.ts
··· 1 + import * as base64 from "base64-js"; 2 + import { applyUpdate, Doc } from "yjs"; 3 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 4 + import type { Fact } from "src/replicache"; 5 + import type { Attribute } from "src/replicache/attributes"; 6 + import { scanIndexLocal } from "src/replicache/utils"; 7 + 8 + export function extractTitleFromFacts( 9 + rootToken: string, 10 + facts: Fact<Attribute>[], 11 + ): string { 12 + const scan = scanIndexLocal(facts); 13 + const [root] = scan.eav(rootToken, "root/page"); 14 + const rootEntity = root?.data.value || rootToken; 15 + 16 + const [pageType] = scan.eav(rootEntity, "page/type"); 17 + const isCanvas = pageType?.data.value === "canvas"; 18 + 19 + const rawBlocks = isCanvas 20 + ? scan.eav(rootEntity, "canvas/block").sort((a, b) => { 21 + if (a.data.position.y === b.data.position.y) 22 + return a.data.position.x - b.data.position.x; 23 + return a.data.position.y - b.data.position.y; 24 + }) 25 + : scan.eav(rootEntity, "card/block").sort((a, b) => { 26 + if (a.data.position === b.data.position) 27 + return a.id > b.id ? 1 : -1; 28 + return a.data.position > b.data.position ? 1 : -1; 29 + }); 30 + 31 + const blocks = rawBlocks 32 + .map((b) => { 33 + const type = scan.eav(b.data.value, "block/type")[0]; 34 + if ( 35 + !type || 36 + (type.data.value !== "text" && type.data.value !== "heading") 37 + ) 38 + return null; 39 + return b.data; 40 + }) 41 + .filter((b): b is NonNullable<typeof b> => b !== null); 42 + 43 + const firstBlock = blocks[0]; 44 + if (!firstBlock) return "Untitled"; 45 + 46 + const [content] = scan.eav(firstBlock.value, "block/text"); 47 + if (!content) return "Untitled"; 48 + 49 + const doc = new Doc(); 50 + applyUpdate(doc, base64.toByteArray(content.data.value)); 51 + const nodes = doc.getXmlElement("prosemirror").toArray(); 52 + return YJSFragmentToString(nodes[0]) || "Untitled"; 53 + }
+6
supabase/database.types.ts
··· 893 893 permission_tokens: { 894 894 Row: { 895 895 blocked_by_admin: boolean | null 896 + description: string | null 896 897 id: string 897 898 root_entity: string 899 + title: string | null 898 900 } 899 901 Insert: { 900 902 blocked_by_admin?: boolean | null 903 + description?: string | null 901 904 id?: string 902 905 root_entity: string 906 + title?: string | null 903 907 } 904 908 Update: { 905 909 blocked_by_admin?: boolean | null 910 + description?: string | null 906 911 id?: string 907 912 root_entity?: string 913 + title?: string | null 908 914 } 909 915 Relationships: [ 910 916 {