a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+812 -449
-9
.claude/settings.local.json
··· 1 - { 2 - "permissions": { 3 - "allow": [ 4 - "mcp__acp__Edit", 5 - "mcp__acp__Write", 6 - "mcp__acp__Bash" 7 - ] 8 - } 9 - }
+298
.claude/skills/lexicons.md
··· 1 + # Lexicon System 2 + 3 + ## Overview 4 + 5 + Lexicons define the schema for AT Protocol records. This project has two namespaces: 6 + - **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) 7 + - **`site.standard.*`** - Standard site lexicons for interoperability 8 + 9 + The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. 10 + 11 + ## Key Files 12 + 13 + - **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons 14 + - **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) 15 + - **`lexicons/build.ts`** - Builds TypeScript sources to JSON 16 + - **`lexicons/api/`** - Generated TypeScript types and client 17 + - **`package.json`** - Contains `lexgen` script 18 + 19 + ## Running Lexicon Generation 20 + 21 + ```bash 22 + npm run lexgen 23 + ``` 24 + 25 + This runs: 26 + 1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript 27 + 2. `lex gen-api` - Generates TypeScript types from all JSON lexicons 28 + 3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions 29 + 30 + ## Adding a New pub.leaflet Lexicon 31 + 32 + ### 1. Create the Source Definition 33 + 34 + Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): 35 + 36 + ```typescript 37 + import { LexiconDoc } from "@atproto/lexicon"; 38 + 39 + export const PubLeafletMyLexicon: LexiconDoc = { 40 + lexicon: 1, 41 + id: "pub.leaflet.myLexicon", 42 + defs: { 43 + main: { 44 + type: "record", // or "object" for non-record types 45 + key: "tid", 46 + record: { 47 + type: "object", 48 + required: ["field1"], 49 + properties: { 50 + field1: { type: "string", maxLength: 1000 }, 51 + field2: { type: "integer", minimum: 0 }, 52 + optionalRef: { type: "ref", ref: "other.lexicon#def" }, 53 + }, 54 + }, 55 + }, 56 + // Additional defs for sub-objects 57 + subType: { 58 + type: "object", 59 + properties: { 60 + nested: { type: "string" }, 61 + }, 62 + }, 63 + }, 64 + }; 65 + ``` 66 + 67 + ### 2. Add to Build 68 + 69 + Update `lexicons/build.ts`: 70 + 71 + ```typescript 72 + import { PubLeafletMyLexicon } from "./src/myLexicon"; 73 + 74 + const lexicons = [ 75 + // ... existing lexicons 76 + PubLeafletMyLexicon, 77 + ]; 78 + ``` 79 + 80 + ### 3. Update lexgen Command (if needed) 81 + 82 + If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: 83 + 84 + ```json 85 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." 86 + ``` 87 + 88 + Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. 89 + 90 + ### 4. Add to authFullPermissions (for record types) 91 + 92 + If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`: 93 + 94 + ```typescript 95 + import { PubLeafletMyLexicon } from "./myLexicon"; 96 + 97 + // In the permissions collection array: 98 + collection: [ 99 + // ... existing lexicons 100 + PubLeafletMyLexicon.id, 101 + ], 102 + ``` 103 + 104 + ### 5. Regenerate Types 105 + 106 + ```bash 107 + npm run lexgen 108 + ``` 109 + 110 + ### 6. Use the Generated Types 111 + 112 + ```typescript 113 + import { PubLeafletMyLexicon } from "lexicons/api"; 114 + 115 + // Type for the record 116 + type MyRecord = PubLeafletMyLexicon.Record; 117 + 118 + // Validation 119 + const result = PubLeafletMyLexicon.validateRecord(data); 120 + if (result.success) { 121 + // result.value is typed 122 + } 123 + 124 + // Type guard 125 + if (PubLeafletMyLexicon.isRecord(data)) { 126 + // data is typed as Record 127 + } 128 + ``` 129 + 130 + ## Adding a New site.standard Lexicon 131 + 132 + ### 1. Create the JSON Definition 133 + 134 + Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): 135 + 136 + ```json 137 + { 138 + "lexicon": 1, 139 + "id": "site.standard.myType", 140 + "defs": { 141 + "main": { 142 + "type": "record", 143 + "key": "tid", 144 + "record": { 145 + "type": "object", 146 + "required": ["field1"], 147 + "properties": { 148 + "field1": { 149 + "type": "string", 150 + "maxLength": 1000 151 + } 152 + } 153 + } 154 + } 155 + } 156 + } 157 + ``` 158 + 159 + ### 2. Regenerate Types 160 + 161 + ```bash 162 + npm run lexgen 163 + ``` 164 + 165 + The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. 166 + 167 + ## Common Lexicon Patterns 168 + 169 + ### Referencing Other Lexicons 170 + 171 + ```typescript 172 + // Reference another lexicon's main def 173 + { type: "ref", ref: "pub.leaflet.publication" } 174 + 175 + // Reference a specific def within a lexicon 176 + { type: "ref", ref: "pub.leaflet.publication#theme" } 177 + 178 + // Reference within the same lexicon 179 + { type: "ref", ref: "#myDef" } 180 + ``` 181 + 182 + ### Union Types 183 + 184 + ```typescript 185 + { 186 + type: "union", 187 + refs: [ 188 + "pub.leaflet.pages.linearDocument", 189 + "pub.leaflet.pages.canvas", 190 + ], 191 + } 192 + 193 + // Open union (allows unknown types) 194 + { 195 + type: "union", 196 + closed: false, // default is true 197 + refs: ["pub.leaflet.content"], 198 + } 199 + ``` 200 + 201 + ### Blob Types (for images/files) 202 + 203 + ```typescript 204 + { 205 + type: "blob", 206 + accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] 207 + maxSize: 1000000, // bytes 208 + } 209 + ``` 210 + 211 + ### Color Types 212 + 213 + The project has color types defined: 214 + - `pub.leaflet.theme.color#rgb` / `#rgba` 215 + - `site.standard.theme.color#rgb` / `#rgba` 216 + 217 + ```typescript 218 + // In lexicons/src/theme.ts 219 + export const ColorUnion = { 220 + type: "union", 221 + refs: [ 222 + "pub.leaflet.theme.color#rgba", 223 + "pub.leaflet.theme.color#rgb", 224 + ], 225 + }; 226 + ``` 227 + 228 + ## Normalization Between Formats 229 + 230 + Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: 231 + 232 + ```typescript 233 + import { 234 + normalizeDocument, 235 + normalizePublication, 236 + isLeafletDocument, 237 + isStandardDocument, 238 + getDocumentPages, 239 + } from "lexicons/src/normalize"; 240 + 241 + // Normalize a document from either format 242 + const normalized = normalizeDocument(record); 243 + if (normalized) { 244 + // normalized is always in site.standard.document format 245 + console.log(normalized.title, normalized.site); 246 + 247 + // Get pages if content is pub.leaflet.content 248 + const pages = getDocumentPages(normalized); 249 + } 250 + 251 + // Normalize a publication 252 + const pub = normalizePublication(record); 253 + if (pub) { 254 + console.log(pub.name, pub.url); 255 + } 256 + ``` 257 + 258 + ## Handling in Appview (Firehose Consumer) 259 + 260 + When processing records from the firehose in `appview/index.ts`: 261 + 262 + ```typescript 263 + import { ids } from "lexicons/api/lexicons"; 264 + import { PubLeafletMyLexicon } from "lexicons/api"; 265 + 266 + // In filterCollections: 267 + filterCollections: [ 268 + ids.PubLeafletMyLexicon, 269 + // ... 270 + ], 271 + 272 + // In handleEvent: 273 + if (evt.collection === ids.PubLeafletMyLexicon) { 274 + if (evt.event === "create" || evt.event === "update") { 275 + let record = PubLeafletMyLexicon.validateRecord(evt.record); 276 + if (!record.success) return; 277 + 278 + // Store in database 279 + await supabase.from("my_table").upsert({ 280 + uri: evt.uri.toString(), 281 + data: record.value as Json, 282 + }); 283 + } 284 + if (evt.event === "delete") { 285 + await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); 286 + } 287 + } 288 + ``` 289 + 290 + ## Publishing Lexicons 291 + 292 + To publish lexicons to an AT Protocol PDS: 293 + 294 + ```bash 295 + npm run publish-lexicons 296 + ``` 297 + 298 + This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
+144
.claude/skills/notifications.md
··· 1 + # Notification System 2 + 3 + ## Overview 4 + 5 + Notifications are stored in the database and hydrated with related data before being rendered. The system supports multiple notification types (comments, subscriptions, etc.) that are processed in parallel. 6 + 7 + ## Key Files 8 + 9 + - **`src/notifications.ts`** - Core notification types and hydration logic 10 + - **`app/(home-pages)/notifications/NotificationList.tsx`** - Renders all notification types 11 + - **`app/(home-pages)/notifications/Notification.tsx`** - Base notification component 12 + - Individual notification components (e.g., `CommentNotification.tsx`, `FollowNotification.tsx`) 13 + 14 + ## Adding a New Notification Type 15 + 16 + ### 1. Update Notification Data Types (`src/notifications.ts`) 17 + 18 + Add your type to the `NotificationData` union: 19 + 20 + ```typescript 21 + export type NotificationData = 22 + | { type: "comment"; comment_uri: string; parent_uri?: string } 23 + | { type: "subscribe"; subscription_uri: string } 24 + | { type: "your_type"; your_field: string }; // Add here 25 + ``` 26 + 27 + Add to the `HydratedNotification` union: 28 + 29 + ```typescript 30 + export type HydratedNotification = 31 + | HydratedCommentNotification 32 + | HydratedSubscribeNotification 33 + | HydratedYourNotification; // Add here 34 + ``` 35 + 36 + ### 2. Create Hydration Function (`src/notifications.ts`) 37 + 38 + ```typescript 39 + export type HydratedYourNotification = Awaited< 40 + ReturnType<typeof hydrateYourNotifications> 41 + >[0]; 42 + 43 + async function hydrateYourNotifications(notifications: NotificationRow[]) { 44 + const yourNotifications = notifications.filter( 45 + (n): n is NotificationRow & { data: ExtractNotificationType<"your_type"> } => 46 + (n.data as NotificationData)?.type === "your_type", 47 + ); 48 + 49 + if (yourNotifications.length === 0) return []; 50 + 51 + // Fetch related data with joins 52 + const { data } = await supabaseServerClient 53 + .from("your_table") 54 + .select("*, related_table(*)") 55 + .in("uri", yourNotifications.map((n) => n.data.your_field)); 56 + 57 + return yourNotifications.map((notification) => ({ 58 + id: notification.id, 59 + recipient: notification.recipient, 60 + created_at: notification.created_at, 61 + type: "your_type" as const, 62 + your_field: notification.data.your_field, 63 + yourData: data?.find((d) => d.uri === notification.data.your_field)!, 64 + })); 65 + } 66 + ``` 67 + 68 + Add to `hydrateNotifications` parallel array: 69 + 70 + ```typescript 71 + const [commentNotifications, subscribeNotifications, yourNotifications] = await Promise.all([ 72 + hydrateCommentNotifications(notifications), 73 + hydrateSubscribeNotifications(notifications), 74 + hydrateYourNotifications(notifications), // Add here 75 + ]); 76 + 77 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...yourNotifications]; 78 + ``` 79 + 80 + ### 3. Trigger the Notification (in your action file) 81 + 82 + ```typescript 83 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 84 + import { v7 } from "uuid"; 85 + 86 + // When the event occurs: 87 + const recipient = /* determine who should receive it */; 88 + if (recipient !== currentUser) { 89 + const notification: Notification = { 90 + id: v7(), 91 + recipient, 92 + data: { 93 + type: "your_type", 94 + your_field: "value", 95 + }, 96 + }; 97 + await supabaseServerClient.from("notifications").insert(notification); 98 + await pingIdentityToUpdateNotification(recipient); 99 + } 100 + ``` 101 + 102 + ### 4. Create Notification Component 103 + 104 + Create a new component (e.g., `YourNotification.tsx`): 105 + 106 + ```typescript 107 + import { HydratedYourNotification } from "src/notifications"; 108 + import { Notification } from "./Notification"; 109 + 110 + export const YourNotification = (props: HydratedYourNotification) => { 111 + // Extract data from props.yourData 112 + 113 + return ( 114 + <Notification 115 + timestamp={props.created_at} 116 + href={/* link to relevant page */} 117 + icon={/* icon or avatar */} 118 + actionText={<>Message to display</>} 119 + content={/* optional additional content */} 120 + /> 121 + ); 122 + }; 123 + ``` 124 + 125 + ### 5. Update NotificationList (`NotificationList.tsx`) 126 + 127 + Import and render your notification type: 128 + 129 + ```typescript 130 + import { YourNotification } from "./YourNotification"; 131 + 132 + // In the map function: 133 + if (n.type === "your_type") { 134 + return <YourNotification key={n.id} {...n} />; 135 + } 136 + ``` 137 + 138 + ## Example: Subscribe Notifications 139 + 140 + See the implementation in: 141 + - `src/notifications.ts:88-125` - Hydration logic 142 + - `app/lish/subscribeToPublication.ts:55-68` - Trigger 143 + - `app/(home-pages)/notifications/FollowNotification.tsx` - Component 144 + - `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering
+3
.gitignore
··· 12 12 .env.local 13 13 .wrangler 14 14 tsconfig.tsbuildinfo 15 + 16 + # Claude 17 + .claude/settings.local.json
+24
appview/index.ts
··· 11 11 PubLeafletComment, 12 12 PubLeafletPollVote, 13 13 PubLeafletPollDefinition, 14 + PubLeafletInteractionsRecommend, 14 15 SiteStandardDocument, 15 16 SiteStandardPublication, 16 17 SiteStandardGraphSubscription, ··· 48 49 ids.PubLeafletComment, 49 50 ids.PubLeafletPollVote, 50 51 ids.PubLeafletPollDefinition, 52 + ids.PubLeafletInteractionsRecommend, 51 53 // ids.AppBskyActorProfile, 52 54 "app.bsky.feed.post", 53 55 ids.SiteStandardDocument, ··· 206 208 if (evt.event === "delete") { 207 209 await supabase 208 210 .from("atp_poll_records") 211 + .delete() 212 + .eq("uri", evt.uri.toString()); 213 + } 214 + } 215 + if (evt.collection === ids.PubLeafletInteractionsRecommend) { 216 + if (evt.event === "create" || evt.event === "update") { 217 + let record = PubLeafletInteractionsRecommend.validateRecord(evt.record); 218 + if (!record.success) return; 219 + await supabase 220 + .from("identities") 221 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 222 + let { error } = await supabase.from("recommends_on_documents").upsert({ 223 + uri: evt.uri.toString(), 224 + recommender_did: evt.did, 225 + document: record.value.subject, 226 + record: record.value as Json, 227 + }); 228 + if (error) console.log("Error upserting recommend:", error); 229 + } 230 + if (evt.event === "delete") { 231 + await supabase 232 + .from("recommends_on_documents") 209 233 .delete() 210 234 .eq("uri", evt.uri.toString()); 211 235 }
+22 -10
components/ActionBar/Publications.tsx
··· 62 62 </> 63 63 )} 64 64 65 - {identity.publications?.map((d) => { 66 - return ( 67 - <PublicationOption 68 - {...d} 69 - key={d.uri} 70 - record={d.record} 71 - current={d.uri === props.currentPubUri} 72 - /> 73 - ); 74 - })} 65 + {identity.publications 66 + ?.filter((p) => { 67 + let record = p.record as any; 68 + if (record.preferences?.greengale) return false; 69 + if ( 70 + record.theme && 71 + record.theme.$type && 72 + record.theme.$type !== "pub.leaflet.publication#theme" 73 + ) 74 + return false; 75 + return true; 76 + }) 77 + .map((d) => { 78 + return ( 79 + <PublicationOption 80 + {...d} 81 + key={d.uri} 82 + record={d.record} 83 + current={d.uri === props.currentPubUri} 84 + /> 85 + ); 86 + })} 75 87 <Link 76 88 href={"/lish/createPub"} 77 89 className="pubListCreateNew text-accent-contrast text-sm place-self-end hover:text-accent-contrast"
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 70 70 areYouSure, 71 71 setAreYouSure, 72 72 }); 73 - undoManager.endGroup(); 73 + setTimeout(() => undoManager.endGroup(), 100); 74 74 }; 75 75 window.addEventListener("keydown", listener); 76 76 return () => window.removeEventListener("keydown", listener);
+97
lexicons/api/index.ts
··· 41 41 import * as PubLeafletContent from './types/pub/leaflet/content' 42 42 import * as PubLeafletDocument from './types/pub/leaflet/document' 43 43 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 44 + import * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' 44 45 import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 45 46 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 46 47 import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' ··· 87 88 export * as PubLeafletContent from './types/pub/leaflet/content' 88 89 export * as PubLeafletDocument from './types/pub/leaflet/document' 89 90 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 91 + export * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' 90 92 export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 91 93 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 92 94 export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' ··· 408 410 publication: PubLeafletPublicationRecord 409 411 blocks: PubLeafletBlocksNS 410 412 graph: PubLeafletGraphNS 413 + interactions: PubLeafletInteractionsNS 411 414 pages: PubLeafletPagesNS 412 415 poll: PubLeafletPollNS 413 416 richtext: PubLeafletRichtextNS ··· 417 420 this._client = client 418 421 this.blocks = new PubLeafletBlocksNS(client) 419 422 this.graph = new PubLeafletGraphNS(client) 423 + this.interactions = new PubLeafletInteractionsNS(client) 420 424 this.pages = new PubLeafletPagesNS(client) 421 425 this.poll = new PubLeafletPollNS(client) 422 426 this.richtext = new PubLeafletRichtextNS(client) ··· 523 527 'com.atproto.repo.deleteRecord', 524 528 undefined, 525 529 { collection: 'pub.leaflet.graph.subscription', ...params }, 530 + { headers }, 531 + ) 532 + } 533 + } 534 + 535 + export class PubLeafletInteractionsNS { 536 + _client: XrpcClient 537 + recommend: PubLeafletInteractionsRecommendRecord 538 + 539 + constructor(client: XrpcClient) { 540 + this._client = client 541 + this.recommend = new PubLeafletInteractionsRecommendRecord(client) 542 + } 543 + } 544 + 545 + export class PubLeafletInteractionsRecommendRecord { 546 + _client: XrpcClient 547 + 548 + constructor(client: XrpcClient) { 549 + this._client = client 550 + } 551 + 552 + async list( 553 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 554 + ): Promise<{ 555 + cursor?: string 556 + records: { uri: string; value: PubLeafletInteractionsRecommend.Record }[] 557 + }> { 558 + const res = await this._client.call('com.atproto.repo.listRecords', { 559 + collection: 'pub.leaflet.interactions.recommend', 560 + ...params, 561 + }) 562 + return res.data 563 + } 564 + 565 + async get( 566 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 567 + ): Promise<{ 568 + uri: string 569 + cid: string 570 + value: PubLeafletInteractionsRecommend.Record 571 + }> { 572 + const res = await this._client.call('com.atproto.repo.getRecord', { 573 + collection: 'pub.leaflet.interactions.recommend', 574 + ...params, 575 + }) 576 + return res.data 577 + } 578 + 579 + async create( 580 + params: OmitKey< 581 + ComAtprotoRepoCreateRecord.InputSchema, 582 + 'collection' | 'record' 583 + >, 584 + record: Un$Typed<PubLeafletInteractionsRecommend.Record>, 585 + headers?: Record<string, string>, 586 + ): Promise<{ uri: string; cid: string }> { 587 + const collection = 'pub.leaflet.interactions.recommend' 588 + const res = await this._client.call( 589 + 'com.atproto.repo.createRecord', 590 + undefined, 591 + { collection, ...params, record: { ...record, $type: collection } }, 592 + { encoding: 'application/json', headers }, 593 + ) 594 + return res.data 595 + } 596 + 597 + async put( 598 + params: OmitKey< 599 + ComAtprotoRepoPutRecord.InputSchema, 600 + 'collection' | 'record' 601 + >, 602 + record: Un$Typed<PubLeafletInteractionsRecommend.Record>, 603 + headers?: Record<string, string>, 604 + ): Promise<{ uri: string; cid: string }> { 605 + const collection = 'pub.leaflet.interactions.recommend' 606 + const res = await this._client.call( 607 + 'com.atproto.repo.putRecord', 608 + undefined, 609 + { collection, ...params, record: { ...record, $type: collection } }, 610 + { encoding: 'application/json', headers }, 611 + ) 612 + return res.data 613 + } 614 + 615 + async delete( 616 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 617 + headers?: Record<string, string>, 618 + ): Promise<void> { 619 + await this._client.call( 620 + 'com.atproto.repo.deleteRecord', 621 + undefined, 622 + { collection: 'pub.leaflet.interactions.recommend', ...params }, 526 623 { headers }, 527 624 ) 528 625 }
+26
lexicons/api/lexicons.ts
··· 1517 1517 }, 1518 1518 }, 1519 1519 }, 1520 + PubLeafletInteractionsRecommend: { 1521 + lexicon: 1, 1522 + id: 'pub.leaflet.interactions.recommend', 1523 + defs: { 1524 + main: { 1525 + type: 'record', 1526 + key: 'tid', 1527 + description: 'Record representing a recommend on a document', 1528 + record: { 1529 + type: 'object', 1530 + required: ['subject', 'createdAt'], 1531 + properties: { 1532 + subject: { 1533 + type: 'string', 1534 + format: 'at-uri', 1535 + }, 1536 + createdAt: { 1537 + type: 'string', 1538 + format: 'datetime', 1539 + }, 1540 + }, 1541 + }, 1542 + }, 1543 + }, 1544 + }, 1520 1545 PubLeafletPagesCanvas: { 1521 1546 lexicon: 1, 1522 1547 id: 'pub.leaflet.pages.canvas', ··· 2418 2443 PubLeafletContent: 'pub.leaflet.content', 2419 2444 PubLeafletDocument: 'pub.leaflet.document', 2420 2445 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2446 + PubLeafletInteractionsRecommend: 'pub.leaflet.interactions.recommend', 2421 2447 PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 2422 2448 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2423 2449 PubLeafletPollDefinition: 'pub.leaflet.poll.definition',
+32
lexicons/api/types/pub/leaflet/interactions/recommend.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.interactions.recommend' 16 + 17 + export interface Record { 18 + $type: 'pub.leaflet.interactions.recommend' 19 + subject: string 20 + createdAt: string 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+2
lexicons/build.ts
··· 3 3 import { PubLeafletDocument } from "./src/document"; 4 4 import * as PublicationLexicons from "./src/publication"; 5 5 import * as PollLexicons from "./src/polls"; 6 + import * as InteractionsLexicons from "./src/interactions"; 6 7 import { ThemeLexicons } from "./src/theme"; 7 8 8 9 import * as fs from "fs"; ··· 31 32 ...BlockLexicons, 32 33 ...Object.values(PublicationLexicons), 33 34 ...Object.values(PollLexicons), 35 + ...Object.values(InteractionsLexicons), 34 36 ]; 35 37 36 38 // Write each lexicon to a file
+2 -1
lexicons/pub/leaflet/authFullPermissions.json
··· 21 21 "pub.leaflet.comment", 22 22 "pub.leaflet.poll.definition", 23 23 "pub.leaflet.poll.vote", 24 - "pub.leaflet.graph.subscription" 24 + "pub.leaflet.graph.subscription", 25 + "pub.leaflet.interactions.recommend" 25 26 ] 26 27 } 27 28 ]
+28
lexicons/pub/leaflet/interactions/recommend.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.interactions.recommend", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record representing a recommend on a document", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "subject", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "createdAt": { 21 + "type": "string", 22 + "format": "datetime" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+2
lexicons/src/authFullPermissions.ts
··· 6 6 } from "./publication"; 7 7 import { PubLeafletComment } from "./comment"; 8 8 import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; 9 + import { PubLeafletInteractionsRecommend } from "./interactions"; 9 10 10 11 export const PubLeafletAuthFullPermissions: LexiconDoc = { 11 12 lexicon: 1, ··· 28 29 PubLeafletPollDefinition.id, 29 30 PubLeafletPollVote.id, 30 31 PubLeafletPublicationSubscription.id, 32 + PubLeafletInteractionsRecommend.id, 31 33 ], 32 34 }, 33 35 ],
+21
lexicons/src/interactions/index.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + 3 + export const PubLeafletInteractionsRecommend: LexiconDoc = { 4 + lexicon: 1, 5 + id: "pub.leaflet.interactions.recommend", 6 + defs: { 7 + main: { 8 + type: "record", 9 + key: "tid", 10 + description: "Record representing a recommend on a document", 11 + record: { 12 + type: "object", 13 + required: ["subject", "createdAt"], 14 + properties: { 15 + subject: { type: "string", format: "at-uri" }, 16 + createdAt: { type: "string", format: "datetime" }, 17 + }, 18 + }, 19 + }, 20 + }, 21 + };
-284
patterns/lexicons.md
··· 1 - # Lexicon System 2 - 3 - ## Overview 4 - 5 - Lexicons define the schema for AT Protocol records. This project has two namespaces: 6 - - **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) 7 - - **`site.standard.*`** - Standard site lexicons for interoperability 8 - 9 - The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. 10 - 11 - ## Key Files 12 - 13 - - **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons 14 - - **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) 15 - - **`lexicons/build.ts`** - Builds TypeScript sources to JSON 16 - - **`lexicons/api/`** - Generated TypeScript types and client 17 - - **`package.json`** - Contains `lexgen` script 18 - 19 - ## Running Lexicon Generation 20 - 21 - ```bash 22 - npm run lexgen 23 - ``` 24 - 25 - This runs: 26 - 1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript 27 - 2. `lex gen-api` - Generates TypeScript types from all JSON lexicons 28 - 3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions 29 - 30 - ## Adding a New pub.leaflet Lexicon 31 - 32 - ### 1. Create the Source Definition 33 - 34 - Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): 35 - 36 - ```typescript 37 - import { LexiconDoc } from "@atproto/lexicon"; 38 - 39 - export const PubLeafletMyLexicon: LexiconDoc = { 40 - lexicon: 1, 41 - id: "pub.leaflet.myLexicon", 42 - defs: { 43 - main: { 44 - type: "record", // or "object" for non-record types 45 - key: "tid", 46 - record: { 47 - type: "object", 48 - required: ["field1"], 49 - properties: { 50 - field1: { type: "string", maxLength: 1000 }, 51 - field2: { type: "integer", minimum: 0 }, 52 - optionalRef: { type: "ref", ref: "other.lexicon#def" }, 53 - }, 54 - }, 55 - }, 56 - // Additional defs for sub-objects 57 - subType: { 58 - type: "object", 59 - properties: { 60 - nested: { type: "string" }, 61 - }, 62 - }, 63 - }, 64 - }; 65 - ``` 66 - 67 - ### 2. Add to Build 68 - 69 - Update `lexicons/build.ts`: 70 - 71 - ```typescript 72 - import { PubLeafletMyLexicon } from "./src/myLexicon"; 73 - 74 - const lexicons = [ 75 - // ... existing lexicons 76 - PubLeafletMyLexicon, 77 - ]; 78 - ``` 79 - 80 - ### 3. Update lexgen Command (if needed) 81 - 82 - If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: 83 - 84 - ```json 85 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." 86 - ``` 87 - 88 - Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. 89 - 90 - ### 4. Regenerate Types 91 - 92 - ```bash 93 - npm run lexgen 94 - ``` 95 - 96 - ### 5. Use the Generated Types 97 - 98 - ```typescript 99 - import { PubLeafletMyLexicon } from "lexicons/api"; 100 - 101 - // Type for the record 102 - type MyRecord = PubLeafletMyLexicon.Record; 103 - 104 - // Validation 105 - const result = PubLeafletMyLexicon.validateRecord(data); 106 - if (result.success) { 107 - // result.value is typed 108 - } 109 - 110 - // Type guard 111 - if (PubLeafletMyLexicon.isRecord(data)) { 112 - // data is typed as Record 113 - } 114 - ``` 115 - 116 - ## Adding a New site.standard Lexicon 117 - 118 - ### 1. Create the JSON Definition 119 - 120 - Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): 121 - 122 - ```json 123 - { 124 - "lexicon": 1, 125 - "id": "site.standard.myType", 126 - "defs": { 127 - "main": { 128 - "type": "record", 129 - "key": "tid", 130 - "record": { 131 - "type": "object", 132 - "required": ["field1"], 133 - "properties": { 134 - "field1": { 135 - "type": "string", 136 - "maxLength": 1000 137 - } 138 - } 139 - } 140 - } 141 - } 142 - } 143 - ``` 144 - 145 - ### 2. Regenerate Types 146 - 147 - ```bash 148 - npm run lexgen 149 - ``` 150 - 151 - The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. 152 - 153 - ## Common Lexicon Patterns 154 - 155 - ### Referencing Other Lexicons 156 - 157 - ```typescript 158 - // Reference another lexicon's main def 159 - { type: "ref", ref: "pub.leaflet.publication" } 160 - 161 - // Reference a specific def within a lexicon 162 - { type: "ref", ref: "pub.leaflet.publication#theme" } 163 - 164 - // Reference within the same lexicon 165 - { type: "ref", ref: "#myDef" } 166 - ``` 167 - 168 - ### Union Types 169 - 170 - ```typescript 171 - { 172 - type: "union", 173 - refs: [ 174 - "pub.leaflet.pages.linearDocument", 175 - "pub.leaflet.pages.canvas", 176 - ], 177 - } 178 - 179 - // Open union (allows unknown types) 180 - { 181 - type: "union", 182 - closed: false, // default is true 183 - refs: ["pub.leaflet.content"], 184 - } 185 - ``` 186 - 187 - ### Blob Types (for images/files) 188 - 189 - ```typescript 190 - { 191 - type: "blob", 192 - accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] 193 - maxSize: 1000000, // bytes 194 - } 195 - ``` 196 - 197 - ### Color Types 198 - 199 - The project has color types defined: 200 - - `pub.leaflet.theme.color#rgb` / `#rgba` 201 - - `site.standard.theme.color#rgb` / `#rgba` 202 - 203 - ```typescript 204 - // In lexicons/src/theme.ts 205 - export const ColorUnion = { 206 - type: "union", 207 - refs: [ 208 - "pub.leaflet.theme.color#rgba", 209 - "pub.leaflet.theme.color#rgb", 210 - ], 211 - }; 212 - ``` 213 - 214 - ## Normalization Between Formats 215 - 216 - Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: 217 - 218 - ```typescript 219 - import { 220 - normalizeDocument, 221 - normalizePublication, 222 - isLeafletDocument, 223 - isStandardDocument, 224 - getDocumentPages, 225 - } from "lexicons/src/normalize"; 226 - 227 - // Normalize a document from either format 228 - const normalized = normalizeDocument(record); 229 - if (normalized) { 230 - // normalized is always in site.standard.document format 231 - console.log(normalized.title, normalized.site); 232 - 233 - // Get pages if content is pub.leaflet.content 234 - const pages = getDocumentPages(normalized); 235 - } 236 - 237 - // Normalize a publication 238 - const pub = normalizePublication(record); 239 - if (pub) { 240 - console.log(pub.name, pub.url); 241 - } 242 - ``` 243 - 244 - ## Handling in Appview (Firehose Consumer) 245 - 246 - When processing records from the firehose in `appview/index.ts`: 247 - 248 - ```typescript 249 - import { ids } from "lexicons/api/lexicons"; 250 - import { PubLeafletMyLexicon } from "lexicons/api"; 251 - 252 - // In filterCollections: 253 - filterCollections: [ 254 - ids.PubLeafletMyLexicon, 255 - // ... 256 - ], 257 - 258 - // In handleEvent: 259 - if (evt.collection === ids.PubLeafletMyLexicon) { 260 - if (evt.event === "create" || evt.event === "update") { 261 - let record = PubLeafletMyLexicon.validateRecord(evt.record); 262 - if (!record.success) return; 263 - 264 - // Store in database 265 - await supabase.from("my_table").upsert({ 266 - uri: evt.uri.toString(), 267 - data: record.value as Json, 268 - }); 269 - } 270 - if (evt.event === "delete") { 271 - await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); 272 - } 273 - } 274 - ``` 275 - 276 - ## Publishing Lexicons 277 - 278 - To publish lexicons to an AT Protocol PDS: 279 - 280 - ```bash 281 - npm run publish-lexicons 282 - ``` 283 - 284 - This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
-144
patterns/notifications.md
··· 1 - # Notification System 2 - 3 - ## Overview 4 - 5 - Notifications are stored in the database and hydrated with related data before being rendered. The system supports multiple notification types (comments, subscriptions, etc.) that are processed in parallel. 6 - 7 - ## Key Files 8 - 9 - - **`src/notifications.ts`** - Core notification types and hydration logic 10 - - **`app/(home-pages)/notifications/NotificationList.tsx`** - Renders all notification types 11 - - **`app/(home-pages)/notifications/Notification.tsx`** - Base notification component 12 - - Individual notification components (e.g., `CommentNotification.tsx`, `FollowNotification.tsx`) 13 - 14 - ## Adding a New Notification Type 15 - 16 - ### 1. Update Notification Data Types (`src/notifications.ts`) 17 - 18 - Add your type to the `NotificationData` union: 19 - 20 - ```typescript 21 - export type NotificationData = 22 - | { type: "comment"; comment_uri: string; parent_uri?: string } 23 - | { type: "subscribe"; subscription_uri: string } 24 - | { type: "your_type"; your_field: string }; // Add here 25 - ``` 26 - 27 - Add to the `HydratedNotification` union: 28 - 29 - ```typescript 30 - export type HydratedNotification = 31 - | HydratedCommentNotification 32 - | HydratedSubscribeNotification 33 - | HydratedYourNotification; // Add here 34 - ``` 35 - 36 - ### 2. Create Hydration Function (`src/notifications.ts`) 37 - 38 - ```typescript 39 - export type HydratedYourNotification = Awaited< 40 - ReturnType<typeof hydrateYourNotifications> 41 - >[0]; 42 - 43 - async function hydrateYourNotifications(notifications: NotificationRow[]) { 44 - const yourNotifications = notifications.filter( 45 - (n): n is NotificationRow & { data: ExtractNotificationType<"your_type"> } => 46 - (n.data as NotificationData)?.type === "your_type", 47 - ); 48 - 49 - if (yourNotifications.length === 0) return []; 50 - 51 - // Fetch related data with joins 52 - const { data } = await supabaseServerClient 53 - .from("your_table") 54 - .select("*, related_table(*)") 55 - .in("uri", yourNotifications.map((n) => n.data.your_field)); 56 - 57 - return yourNotifications.map((notification) => ({ 58 - id: notification.id, 59 - recipient: notification.recipient, 60 - created_at: notification.created_at, 61 - type: "your_type" as const, 62 - your_field: notification.data.your_field, 63 - yourData: data?.find((d) => d.uri === notification.data.your_field)!, 64 - })); 65 - } 66 - ``` 67 - 68 - Add to `hydrateNotifications` parallel array: 69 - 70 - ```typescript 71 - const [commentNotifications, subscribeNotifications, yourNotifications] = await Promise.all([ 72 - hydrateCommentNotifications(notifications), 73 - hydrateSubscribeNotifications(notifications), 74 - hydrateYourNotifications(notifications), // Add here 75 - ]); 76 - 77 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...yourNotifications]; 78 - ``` 79 - 80 - ### 3. Trigger the Notification (in your action file) 81 - 82 - ```typescript 83 - import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 84 - import { v7 } from "uuid"; 85 - 86 - // When the event occurs: 87 - const recipient = /* determine who should receive it */; 88 - if (recipient !== currentUser) { 89 - const notification: Notification = { 90 - id: v7(), 91 - recipient, 92 - data: { 93 - type: "your_type", 94 - your_field: "value", 95 - }, 96 - }; 97 - await supabaseServerClient.from("notifications").insert(notification); 98 - await pingIdentityToUpdateNotification(recipient); 99 - } 100 - ``` 101 - 102 - ### 4. Create Notification Component 103 - 104 - Create a new component (e.g., `YourNotification.tsx`): 105 - 106 - ```typescript 107 - import { HydratedYourNotification } from "src/notifications"; 108 - import { Notification } from "./Notification"; 109 - 110 - export const YourNotification = (props: HydratedYourNotification) => { 111 - // Extract data from props.yourData 112 - 113 - return ( 114 - <Notification 115 - timestamp={props.created_at} 116 - href={/* link to relevant page */} 117 - icon={/* icon or avatar */} 118 - actionText={<>Message to display</>} 119 - content={/* optional additional content */} 120 - /> 121 - ); 122 - }; 123 - ``` 124 - 125 - ### 5. Update NotificationList (`NotificationList.tsx`) 126 - 127 - Import and render your notification type: 128 - 129 - ```typescript 130 - import { YourNotification } from "./YourNotification"; 131 - 132 - // In the map function: 133 - if (n.type === "your_type") { 134 - return <YourNotification key={n.id} {...n} />; 135 - } 136 - ``` 137 - 138 - ## Example: Subscribe Notifications 139 - 140 - See the implementation in: 141 - - `src/notifications.ts:88-125` - Hydration logic 142 - - `app/lish/subscribeToPublication.ts:55-68` - Trigger 143 - - `app/(home-pages)/notifications/FollowNotification.tsx` - Component 144 - - `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering
+45
supabase/database.types.ts
··· 1075 1075 }, 1076 1076 ] 1077 1077 } 1078 + recommends_on_documents: { 1079 + Row: { 1080 + document: string 1081 + indexed_at: string 1082 + recommender_did: string 1083 + record: Json 1084 + uri: string 1085 + } 1086 + Insert: { 1087 + document: string 1088 + indexed_at?: string 1089 + recommender_did: string 1090 + record: Json 1091 + uri: string 1092 + } 1093 + Update: { 1094 + document?: string 1095 + indexed_at?: string 1096 + recommender_did?: string 1097 + record?: Json 1098 + uri?: string 1099 + } 1100 + Relationships: [ 1101 + { 1102 + foreignKeyName: "recommends_on_documents_document_fkey" 1103 + columns: ["document"] 1104 + isOneToOne: false 1105 + referencedRelation: "documents" 1106 + referencedColumns: ["uri"] 1107 + }, 1108 + { 1109 + foreignKeyName: "recommends_on_documents_recommender_did_fkey" 1110 + columns: ["recommender_did"] 1111 + isOneToOne: false 1112 + referencedRelation: "identities" 1113 + referencedColumns: ["atp_did"] 1114 + }, 1115 + ] 1116 + } 1078 1117 replicache_clients: { 1079 1118 Row: { 1080 1119 client_group: string ··· 1303 1342 Returns: { 1304 1343 like: unknown 1305 1344 }[] 1345 + } 1346 + parse_iso_timestamp: { 1347 + Args: { 1348 + "": string 1349 + } 1350 + Returns: string 1306 1351 } 1307 1352 pull_data: { 1308 1353 Args: {
+65
supabase/migrations/20260127000000_add_recommends_table.sql
··· 1 + create table "public"."recommends_on_documents" ( 2 + "uri" text not null, 3 + "record" jsonb not null, 4 + "document" text not null, 5 + "recommender_did" text not null, 6 + "indexed_at" timestamp with time zone not null default now() 7 + ); 8 + 9 + alter table "public"."recommends_on_documents" enable row level security; 10 + 11 + CREATE UNIQUE INDEX recommends_on_documents_pkey ON public.recommends_on_documents USING btree (uri); 12 + 13 + alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_pkey" PRIMARY KEY using index "recommends_on_documents_pkey"; 14 + 15 + CREATE INDEX recommends_on_documents_document_idx ON public.recommends_on_documents USING btree (document); 16 + 17 + CREATE INDEX recommends_on_documents_recommender_did_idx ON public.recommends_on_documents USING btree (recommender_did); 18 + 19 + CREATE UNIQUE INDEX recommends_on_documents_recommender_document_idx ON public.recommends_on_documents USING btree (recommender_did, document); 20 + 21 + alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE; 22 + 23 + alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_recommender_did_fkey" FOREIGN KEY (recommender_did) REFERENCES identities(atp_did) ON UPDATE CASCADE ON DELETE CASCADE; 24 + 25 + grant delete on table "public"."recommends_on_documents" to "anon"; 26 + 27 + grant insert on table "public"."recommends_on_documents" to "anon"; 28 + 29 + grant references on table "public"."recommends_on_documents" to "anon"; 30 + 31 + grant select on table "public"."recommends_on_documents" to "anon"; 32 + 33 + grant trigger on table "public"."recommends_on_documents" to "anon"; 34 + 35 + grant truncate on table "public"."recommends_on_documents" to "anon"; 36 + 37 + grant update on table "public"."recommends_on_documents" to "anon"; 38 + 39 + grant delete on table "public"."recommends_on_documents" to "authenticated"; 40 + 41 + grant insert on table "public"."recommends_on_documents" to "authenticated"; 42 + 43 + grant references on table "public"."recommends_on_documents" to "authenticated"; 44 + 45 + grant select on table "public"."recommends_on_documents" to "authenticated"; 46 + 47 + grant trigger on table "public"."recommends_on_documents" to "authenticated"; 48 + 49 + grant truncate on table "public"."recommends_on_documents" to "authenticated"; 50 + 51 + grant update on table "public"."recommends_on_documents" to "authenticated"; 52 + 53 + grant delete on table "public"."recommends_on_documents" to "service_role"; 54 + 55 + grant insert on table "public"."recommends_on_documents" to "service_role"; 56 + 57 + grant references on table "public"."recommends_on_documents" to "service_role"; 58 + 59 + grant select on table "public"."recommends_on_documents" to "service_role"; 60 + 61 + grant trigger on table "public"."recommends_on_documents" to "service_role"; 62 + 63 + grant truncate on table "public"."recommends_on_documents" to "service_role"; 64 + 65 + grant update on table "public"."recommends_on_documents" to "service_role";