a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+1023 -621
+15
.claude/settings.local.json
···
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "mcp__acp__Edit", 5 + "mcp__acp__Write", 6 + "mcp__acp__Bash", 7 + "mcp__primitive__say_hello", 8 + "mcp__primitive__pending_delegations", 9 + "mcp__primitive__claim_delegation", 10 + "mcp__primitive__tasks_update", 11 + "mcp__primitive__contexts_update", 12 + "mcp__primitive__contexts_list" 13 + ] 14 + } 15 + }
-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.
···
+15 -12
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 26 `*, 27 comments_on_documents(count), 28 document_mentions_in_bsky(count), 29 documents_in_publications(publications(*))`, 30 ) 31 .like("uri", `at://${did}/%`) ··· 39 ); 40 } 41 42 - let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([ 43 - query, 44 - supabaseServerClient 45 - .from("publications") 46 - .select("*") 47 - .eq("identity_did", did), 48 - supabaseServerClient 49 - .from("bsky_profiles") 50 - .select("handle") 51 - .eq("did", did) 52 - .single(), 53 - ]); 54 55 // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 56 const docs = deduplicateByUriOrdered(rawDocs || []); ··· 82 sort_date: doc.sort_date, 83 comments_on_documents: doc.comments_on_documents, 84 document_mentions_in_bsky: doc.document_mentions_in_bsky, 85 }, 86 }; 87
··· 26 `*, 27 comments_on_documents(count), 28 document_mentions_in_bsky(count), 29 + recommends_on_documents(count), 30 documents_in_publications(publications(*))`, 31 ) 32 .like("uri", `at://${did}/%`) ··· 40 ); 41 } 42 43 + let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = 44 + await Promise.all([ 45 + query, 46 + supabaseServerClient 47 + .from("publications") 48 + .select("*") 49 + .eq("identity_did", did), 50 + supabaseServerClient 51 + .from("bsky_profiles") 52 + .select("handle") 53 + .eq("did", did) 54 + .single(), 55 + ]); 56 57 // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 58 const docs = deduplicateByUriOrdered(rawDocs || []); ··· 84 sort_date: doc.sort_date, 85 comments_on_documents: doc.comments_on_documents, 86 document_mentions_in_bsky: doc.document_mentions_in_bsky, 87 + recommends_on_documents: doc.recommends_on_documents, 88 }, 89 }; 90
+3
app/(home-pages)/reader/getReaderFeed.ts
··· 32 `*, 33 comments_on_documents(count), 34 document_mentions_in_bsky(count), 35 documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 36 ) 37 .eq( ··· 76 documents: { 77 comments_on_documents: post.comments_on_documents, 78 document_mentions_in_bsky: post.document_mentions_in_bsky, 79 data: normalizedData, 80 uri: post.uri, 81 sort_date: post.sort_date, ··· 112 sort_date: string; 113 comments_on_documents: { count: number }[] | undefined; 114 document_mentions_in_bsky: { count: number }[] | undefined; 115 }; 116 };
··· 32 `*, 33 comments_on_documents(count), 34 document_mentions_in_bsky(count), 35 + recommends_on_documents(count), 36 documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 37 ) 38 .eq( ··· 77 documents: { 78 comments_on_documents: post.comments_on_documents, 79 document_mentions_in_bsky: post.document_mentions_in_bsky, 80 + recommends_on_documents: post.recommends_on_documents, 81 data: normalizedData, 82 uri: post.uri, 83 sort_date: post.sort_date, ··· 114 sort_date: string; 115 comments_on_documents: { count: number }[] | undefined; 116 document_mentions_in_bsky: { count: number }[] | undefined; 117 + recommends_on_documents: { count: number }[] | undefined; 118 }; 119 };
+2
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 21 `*, 22 comments_on_documents(count), 23 document_mentions_in_bsky(count), 24 documents_in_publications(publications(*))`, 25 ) 26 .contains("data->tags", `["${tag}"]`) ··· 67 documents: { 68 comments_on_documents: doc.comments_on_documents, 69 document_mentions_in_bsky: doc.document_mentions_in_bsky, 70 data: normalizedData, 71 uri: doc.uri, 72 sort_date: doc.sort_date,
··· 21 `*, 22 comments_on_documents(count), 23 document_mentions_in_bsky(count), 24 + recommends_on_documents(count), 25 documents_in_publications(publications(*))`, 26 ) 27 .contains("data->tags", `["${tag}"]`) ··· 68 documents: { 69 comments_on_documents: doc.comments_on_documents, 70 document_mentions_in_bsky: doc.document_mentions_in_bsky, 71 + recommends_on_documents: doc.recommends_on_documents, 72 data: normalizedData, 73 uri: doc.uri, 74 sort_date: doc.sort_date,
+2 -1
app/api/oauth/[route]/route.ts
··· 105 }) 106 .select() 107 .single(); 108 - 109 if (token) await setAuthToken(token.id); 110 111 // Process successful authentication here ··· 114 console.log("User authenticated as:", session.did); 115 return handleAction(s.action, redirectPath); 116 } catch (e) { 117 redirect(redirectPath); 118 } 119 }
··· 105 }) 106 .select() 107 .single(); 108 + console.log({ token }); 109 if (token) await setAuthToken(token.id); 110 111 // Process successful authentication here ··· 114 console.log("User authenticated as:", session.did); 115 return handleAction(s.action, redirectPath); 116 } catch (e) { 117 + console.log(e); 118 redirect(redirectPath); 119 } 120 }
+4 -1
app/api/rpc/[command]/get_publication_data.ts
··· 40 documents_in_publications(documents( 41 *, 42 comments_on_documents(count), 43 - document_mentions_in_bsky(count) 44 )), 45 publication_subscriptions(*, identities(bsky_profiles(*))), 46 publication_domains(*), ··· 87 data: dip.documents.data, 88 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 89 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 90 }; 91 }) 92 .filter((d): d is NonNullable<typeof d> => d !== null);
··· 40 documents_in_publications(documents( 41 *, 42 comments_on_documents(count), 43 + document_mentions_in_bsky(count), 44 + recommends_on_documents(count) 45 )), 46 publication_subscriptions(*, identities(bsky_profiles(*))), 47 publication_domains(*), ··· 88 data: dip.documents.data, 89 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 90 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 91 + recommendsCount: 92 + dip.documents.recommends_on_documents?.[0]?.count || 0, 93 }; 94 }) 95 .filter((d): d is NonNullable<typeof d> => d !== null);
+40
app/api/rpc/[command]/get_user_recommendations.ts
···
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + 6 + export type GetUserRecommendationsReturnType = Awaited< 7 + ReturnType<(typeof get_user_recommendations)["handler"]> 8 + >; 9 + 10 + export const get_user_recommendations = makeRoute({ 11 + route: "get_user_recommendations", 12 + input: z.object({ 13 + documentUris: z.array(z.string()), 14 + }), 15 + handler: async ({ documentUris }, { supabase }: Pick<Env, "supabase">) => { 16 + const identity = await getIdentityData(); 17 + const currentUserDid = identity?.atp_did; 18 + 19 + if (!currentUserDid || documentUris.length === 0) { 20 + return { 21 + result: {} as Record<string, boolean>, 22 + }; 23 + } 24 + 25 + const { data: recommendations } = await supabase 26 + .from("recommends_on_documents") 27 + .select("document") 28 + .eq("recommender_did", currentUserDid) 29 + .in("document", documentUris); 30 + 31 + const recommendedSet = new Set(recommendations?.map((r) => r.document)); 32 + 33 + const result: Record<string, boolean> = {}; 34 + for (const uri of documentUris) { 35 + result[uri] = recommendedSet.has(uri); 36 + } 37 + 38 + return { result }; 39 + }, 40 + });
+2
app/api/rpc/[command]/route.ts
··· 14 import { search_publication_names } from "./search_publication_names"; 15 import { search_publication_documents } from "./search_publication_documents"; 16 import { get_profile_data } from "./get_profile_data"; 17 18 let supabase = createClient<Database>( 19 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 41 search_publication_names, 42 search_publication_documents, 43 get_profile_data, 44 ]; 45 export async function POST( 46 req: Request,
··· 14 import { search_publication_names } from "./search_publication_names"; 15 import { search_publication_documents } from "./search_publication_documents"; 16 import { get_profile_data } from "./get_profile_data"; 17 + import { get_user_recommendations } from "./get_user_recommendations"; 18 19 let supabase = createClient<Database>( 20 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 42 search_publication_names, 43 search_publication_documents, 44 get_profile_data, 45 + get_user_recommendations, 46 ]; 47 export async function POST( 48 req: Request,
+3 -1
app/lish/Subscribe.tsx
··· 87 return ( 88 <Popover 89 trigger={ 90 - <div className="text-accent-contrast text-sm">Manage Subscription</div> 91 } 92 > 93 <div className="max-w-sm flex flex-col gap-1">
··· 87 return ( 88 <Popover 89 trigger={ 90 + <div className="text-accent-contrast text-sm w-fit"> 91 + Manage Subscription 92 + </div> 93 } 94 > 95 <div className="max-w-sm flex flex-col gap-1">
+6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 71 preferences={preferences} 72 commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 /> 75 <CanvasContent 76 blocks={blocks} ··· 205 preferences: { 206 showComments?: boolean; 207 showMentions?: boolean; 208 showPrevNext?: boolean; 209 }; 210 quotesCount: number | undefined; 211 commentsCount: number | undefined; 212 }) => { 213 let isMobile = useIsMobile(); 214 return ( ··· 216 <Interactions 217 quotesCount={props.quotesCount || 0} 218 commentsCount={props.commentsCount || 0} 219 showComments={props.preferences.showComments !== false} 220 showMentions={props.preferences.showMentions !== false} 221 pageId={props.pageId} 222 /> 223 {!props.isSubpage && ( ··· 233 data={props.data} 234 profile={props.profile} 235 preferences={props.preferences} 236 /> 237 </Popover> 238 </>
··· 71 preferences={preferences} 72 commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 + recommendsCount={document.recommendsCount} 75 /> 76 <CanvasContent 77 blocks={blocks} ··· 206 preferences: { 207 showComments?: boolean; 208 showMentions?: boolean; 209 + showRecommends?: boolean; 210 showPrevNext?: boolean; 211 }; 212 quotesCount: number | undefined; 213 commentsCount: number | undefined; 214 + recommendsCount: number; 215 }) => { 216 let isMobile = useIsMobile(); 217 return ( ··· 219 <Interactions 220 quotesCount={props.quotesCount || 0} 221 commentsCount={props.commentsCount || 0} 222 + recommendsCount={props.recommendsCount} 223 showComments={props.preferences.showComments !== false} 224 showMentions={props.preferences.showMentions !== false} 225 + showRecommends={props.preferences.showRecommends !== false} 226 pageId={props.pageId} 227 /> 228 {!props.isSubpage && ( ··· 238 data={props.data} 239 profile={props.profile} 240 preferences={props.preferences} 241 + isCanvas 242 /> 243 </Popover> 244 </>
+83 -41
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 19 import { EditTiny } from "components/Icons/EditTiny"; 20 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 21 22 export type InteractionState = { 23 drawerOpen: undefined | boolean; ··· 105 export const Interactions = (props: { 106 quotesCount: number; 107 commentsCount: number; 108 className?: string; 109 showComments: boolean; 110 showMentions: boolean; 111 pageId?: string; 112 }) => { 113 - const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 114 let { identity } = useIdentityData(); 115 116 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 124 const tags = normalizedDocument.tags; 125 const tagCount = tags?.length || 0; 126 127 return ( 128 - <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 129 - {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 130 131 {props.quotesCount === 0 || props.showMentions === false ? null : ( 132 <button 133 - className="flex w-fit gap-2 items-center" 134 onClick={() => { 135 if (!drawerOpen || drawer !== "quotes") 136 openInteractionDrawer("quotes", document_uri, props.pageId); ··· 143 <QuoteTiny aria-hidden /> {props.quotesCount} 144 </button> 145 )} 146 {props.showComments === false ? null : ( 147 <button 148 - className="flex gap-2 items-center w-fit" 149 onClick={() => { 150 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 151 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 <CommentTiny aria-hidden /> {props.commentsCount} 157 </button> 158 )} 159 </div> 160 ); 161 }; ··· 163 export const ExpandedInteractions = (props: { 164 quotesCount: number; 165 commentsCount: number; 166 className?: string; 167 showComments: boolean; 168 showMentions: boolean; 169 pageId?: string; 170 }) => { 171 - const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 172 let { identity } = useIdentityData(); 173 174 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 182 const tags = normalizedDocument.tags; 183 const tagCount = tags?.length || 0; 184 185 - let noInteractions = !props.showComments && !props.showMentions; 186 187 let subscribed = 188 identity?.atp_did && ··· 191 (s) => s.identity === identity.atp_did, 192 ); 193 194 - let isAuthor = 195 - identity && 196 - identity.atp_did === publication?.identity_did && 197 - leafletId; 198 - 199 return ( 200 <div 201 className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} ··· 214 {noInteractions ? ( 215 <div /> 216 ) : ( 217 - <> 218 - <div className="flex gap-2"> 219 {props.quotesCount === 0 || !props.showMentions ? null : ( 220 - <button 221 - className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 222 onClick={() => { 223 if (!drawerOpen || drawer !== "quotes") 224 openInteractionDrawer( ··· 233 onTouchStart={handleQuotePrefetch} 234 aria-label="Post quotes" 235 > 236 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 237 - <span 238 - aria-hidden 239 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 240 - </button> 241 )} 242 {!props.showComments ? null : ( 243 - <button 244 - className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 245 onClick={() => { 246 if ( 247 !drawerOpen || ··· 259 aria-label="Post comments" 260 > 261 <CommentTiny aria-hidden />{" "} 262 - {props.commentsCount > 0 ? ( 263 - <span aria-hidden> 264 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 265 - </span> 266 - ) : ( 267 - "Comment" 268 )} 269 - </button> 270 )} 271 </div> 272 - </> 273 )} 274 275 <EditButton publication={publication} leafletId={leafletId} /> 276 - {subscribed && publication && ( 277 - <ManageSubscription 278 - base_url={getPublicationURL(publication)} 279 - pub_uri={publication.uri} 280 - subscribers={publication.publication_subscriptions} 281 - /> 282 - )} 283 </div> 284 </div> 285 ); ··· 313 </div> 314 ); 315 }; 316 - export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 317 return getQuoteCountFromArray(quotesAndMentions, pageId); 318 } 319 ··· 338 } 339 } 340 341 - export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 342 if (pageId) 343 return comments.filter( 344 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ··· 362 return ( 363 <a 364 href={`https://leaflet.pub/${props.leafletId}`} 365 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 366 > 367 <EditTiny /> Edit Post 368 </a>
··· 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 19 import { EditTiny } from "components/Icons/EditTiny"; 20 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 21 + import { RecommendButton } from "components/RecommendButton"; 22 + import { ButtonSecondary } from "components/Buttons"; 23 + import { Separator } from "components/Layout"; 24 25 export type InteractionState = { 26 drawerOpen: undefined | boolean; ··· 108 export const Interactions = (props: { 109 quotesCount: number; 110 commentsCount: number; 111 + recommendsCount: number; 112 className?: string; 113 showComments: boolean; 114 showMentions: boolean; 115 + showRecommends: boolean; 116 pageId?: string; 117 }) => { 118 + const { 119 + uri: document_uri, 120 + quotesAndMentions, 121 + normalizedDocument, 122 + } = useDocument(); 123 let { identity } = useIdentityData(); 124 125 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 133 const tags = normalizedDocument.tags; 134 const tagCount = tags?.length || 0; 135 136 + let interactionsAvailable = 137 + props.showComments || props.showMentions || props.showRecommends; 138 + 139 return ( 140 + <div 141 + className={`flex gap-[10px] text-tertiary text-sm item-center ${props.className}`} 142 + > 143 + {props.showRecommends === false ? null : ( 144 + <RecommendButton 145 + documentUri={document_uri} 146 + recommendsCount={props.recommendsCount} 147 + /> 148 + )} 149 150 + {/*MENTIONS BUTTON*/} 151 {props.quotesCount === 0 || props.showMentions === false ? null : ( 152 <button 153 + className="flex w-fit gap-1 items-center" 154 onClick={() => { 155 if (!drawerOpen || drawer !== "quotes") 156 openInteractionDrawer("quotes", document_uri, props.pageId); ··· 163 <QuoteTiny aria-hidden /> {props.quotesCount} 164 </button> 165 )} 166 + {/*COMMENT BUTTON*/} 167 {props.showComments === false ? null : ( 168 <button 169 + className="flex gap-1 items-center w-fit" 170 onClick={() => { 171 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 172 openInteractionDrawer("comments", document_uri, props.pageId); ··· 177 <CommentTiny aria-hidden /> {props.commentsCount} 178 </button> 179 )} 180 + 181 + {tagCount > 0 && ( 182 + <> 183 + {interactionsAvailable && <Separator classname="h-4!" />} 184 + <TagPopover tags={tags} tagCount={tagCount} /> 185 + </> 186 + )} 187 </div> 188 ); 189 }; ··· 191 export const ExpandedInteractions = (props: { 192 quotesCount: number; 193 commentsCount: number; 194 + recommendsCount: number; 195 className?: string; 196 showComments: boolean; 197 showMentions: boolean; 198 + showRecommends: boolean; 199 pageId?: string; 200 }) => { 201 + const { 202 + uri: document_uri, 203 + quotesAndMentions, 204 + normalizedDocument, 205 + publication, 206 + leafletId, 207 + } = useDocument(); 208 let { identity } = useIdentityData(); 209 210 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 218 const tags = normalizedDocument.tags; 219 const tagCount = tags?.length || 0; 220 221 + let noInteractions = 222 + !props.showComments && !props.showMentions && !props.showRecommends; 223 224 let subscribed = 225 identity?.atp_did && ··· 228 (s) => s.identity === identity.atp_did, 229 ); 230 231 return ( 232 <div 233 className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} ··· 246 {noInteractions ? ( 247 <div /> 248 ) : ( 249 + <div className="flex flex-col gap-2 just"> 250 + <div className="flex gap-2 sm:flex-row flex-col"> 251 + {props.showRecommends === false ? null : ( 252 + <RecommendButton 253 + documentUri={document_uri} 254 + recommendsCount={props.recommendsCount} 255 + expanded 256 + /> 257 + )} 258 {props.quotesCount === 0 || !props.showMentions ? null : ( 259 + <ButtonSecondary 260 onClick={() => { 261 if (!drawerOpen || drawer !== "quotes") 262 openInteractionDrawer( ··· 271 onTouchStart={handleQuotePrefetch} 272 aria-label="Post quotes" 273 > 274 + <QuoteTiny aria-hidden /> {props.quotesCount} 275 + <Separator classname="h-4! text-accent-contrast!" /> 276 + Mention{props.quotesCount > 1 ? "s" : ""} 277 + </ButtonSecondary> 278 )} 279 {!props.showComments ? null : ( 280 + <ButtonSecondary 281 onClick={() => { 282 if ( 283 !drawerOpen || ··· 295 aria-label="Post comments" 296 > 297 <CommentTiny aria-hidden />{" "} 298 + {props.commentsCount > 0 && ( 299 + <> 300 + {props.commentsCount} 301 + <Separator classname="h-4! text-accent-contrast!" /> 302 + </> 303 )} 304 + Comment{props.commentsCount > 1 ? "s" : ""} 305 + </ButtonSecondary> 306 )} 307 </div> 308 + {subscribed && publication && ( 309 + <ManageSubscription 310 + base_url={getPublicationURL(publication)} 311 + pub_uri={publication.uri} 312 + subscribers={publication.publication_subscriptions} 313 + /> 314 + )} 315 + </div> 316 )} 317 318 <EditButton publication={publication} leafletId={leafletId} /> 319 </div> 320 </div> 321 ); ··· 349 </div> 350 ); 351 }; 352 + export function getQuoteCount( 353 + quotesAndMentions: { uri: string; link?: string }[], 354 + pageId?: string, 355 + ) { 356 return getQuoteCountFromArray(quotesAndMentions, pageId); 357 } 358 ··· 377 } 378 } 379 380 + export function getCommentCount( 381 + comments: CommentOnDocument[], 382 + pageId?: string, 383 + ) { 384 if (pageId) 385 return comments.filter( 386 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ··· 404 return ( 405 <a 406 href={`https://leaflet.pub/${props.leafletId}`} 407 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-md !border-accent-1 !outline-accent-1 h-fit" 408 > 409 <EditTiny /> Edit Post 410 </a>
+135
app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
···
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 + import { TID } from "@atproto/common"; 7 + import { AtUri, Un$Typed } from "@atproto/api"; 8 + import { supabaseServerClient } from "supabase/serverClient"; 9 + import { Json } from "supabase/database.types"; 10 + 11 + type RecommendResult = 12 + | { success: true; uri: string } 13 + | { 14 + success: false; 15 + error: OAuthSessionError | { type: string; message: string }; 16 + }; 17 + 18 + export async function recommendAction(args: { 19 + document: string; 20 + }): Promise<RecommendResult> { 21 + console.log("recommend action..."); 22 + let identity = await getIdentityData(); 23 + if (!identity || !identity.atp_did) { 24 + return { 25 + success: false, 26 + error: { 27 + type: "oauth_session_expired", 28 + message: "Not authenticated", 29 + did: "", 30 + }, 31 + }; 32 + } 33 + 34 + const sessionResult = await restoreOAuthSession(identity.atp_did); 35 + if (!sessionResult.ok) { 36 + return { success: false, error: sessionResult.error }; 37 + } 38 + let credentialSession = sessionResult.value; 39 + let agent = new AtpBaseClient( 40 + credentialSession.fetchHandler.bind(credentialSession), 41 + ); 42 + 43 + let record: Un$Typed<PubLeafletInteractionsRecommend.Record> = { 44 + subject: args.document, 45 + createdAt: new Date().toISOString(), 46 + }; 47 + 48 + let rkey = TID.nextStr(); 49 + let uri = AtUri.make( 50 + credentialSession.did!, 51 + "pub.leaflet.interactions.recommend", 52 + rkey, 53 + ); 54 + 55 + await agent.pub.leaflet.interactions.recommend.create( 56 + { rkey, repo: credentialSession.did! }, 57 + record, 58 + ); 59 + 60 + let res = await supabaseServerClient.from("recommends_on_documents").upsert({ 61 + uri: uri.toString(), 62 + document: args.document, 63 + recommender_did: credentialSession.did!, 64 + record: { 65 + $type: "pub.leaflet.interactions.recommend", 66 + ...record, 67 + } as unknown as Json, 68 + }); 69 + console.log(res); 70 + 71 + return { 72 + success: true, 73 + uri: uri.toString(), 74 + }; 75 + } 76 + 77 + export async function unrecommendAction(args: { 78 + document: string; 79 + }): Promise<RecommendResult> { 80 + let identity = await getIdentityData(); 81 + if (!identity || !identity.atp_did) { 82 + return { 83 + success: false, 84 + error: { 85 + type: "oauth_session_expired", 86 + message: "Not authenticated", 87 + did: "", 88 + }, 89 + }; 90 + } 91 + 92 + const sessionResult = await restoreOAuthSession(identity.atp_did); 93 + if (!sessionResult.ok) { 94 + return { success: false, error: sessionResult.error }; 95 + } 96 + let credentialSession = sessionResult.value; 97 + let agent = new AtpBaseClient( 98 + credentialSession.fetchHandler.bind(credentialSession), 99 + ); 100 + 101 + // Find the existing recommend record 102 + const { data: existingRecommend } = await supabaseServerClient 103 + .from("recommends_on_documents") 104 + .select("uri") 105 + .eq("document", args.document) 106 + .eq("recommender_did", credentialSession.did!) 107 + .single(); 108 + 109 + if (!existingRecommend) { 110 + return { 111 + success: false, 112 + error: { 113 + type: "not_found", 114 + message: "Recommend not found", 115 + }, 116 + }; 117 + } 118 + 119 + let uri = new AtUri(existingRecommend.uri); 120 + 121 + await agent.pub.leaflet.interactions.recommend.delete({ 122 + rkey: uri.rkey, 123 + repo: credentialSession.did!, 124 + }); 125 + 126 + await supabaseServerClient 127 + .from("recommends_on_documents") 128 + .delete() 129 + .eq("uri", existingRecommend.uri); 130 + 131 + return { 132 + success: true, 133 + uri: existingRecommend.uri, 134 + }; 135 + }
+5 -1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 87 pageId={pageId} 88 showComments={preferences.showComments !== false} 89 showMentions={preferences.showMentions !== false} 90 - commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 91 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 92 /> 93 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 94 </PageWrapper>
··· 87 pageId={pageId} 88 showComments={preferences.showComments !== false} 89 showMentions={preferences.showMentions !== false} 90 + showRecommends={preferences.showRecommends !== false} 91 + commentsCount={ 92 + getCommentCount(document.comments_on_documents, pageId) || 0 93 + } 94 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 95 + recommendsCount={document.recommendsCount} 96 /> 97 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 98 </PageWrapper>
+20 -7
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 export function PostHeader(props: { 19 data: PostPageData; 20 profile: ProfileViewDetailed; 21 - preferences: { showComments?: boolean; showMentions?: boolean }; 22 }) { 23 let { identity } = useIdentityData(); 24 let document = props.data; ··· 84 </> 85 ) : null} 86 </div> 87 - <Interactions 88 - showComments={props.preferences.showComments !== false} 89 - showMentions={props.preferences.showMentions !== false} 90 - quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 - commentsCount={getCommentCount(document?.comments_on_documents || []) || 0} 92 - /> 93 </> 94 } 95 />
··· 18 export function PostHeader(props: { 19 data: PostPageData; 20 profile: ProfileViewDetailed; 21 + preferences: { 22 + showComments?: boolean; 23 + showMentions?: boolean; 24 + showRecommends?: boolean; 25 + }; 26 + isCanvas?: boolean; 27 }) { 28 let { identity } = useIdentityData(); 29 let document = props.data; ··· 89 </> 90 ) : null} 91 </div> 92 + {!props.isCanvas && ( 93 + <Interactions 94 + showComments={props.preferences.showComments !== false} 95 + showMentions={props.preferences.showMentions !== false} 96 + showRecommends={props.preferences.showRecommends !== false} 97 + quotesCount={ 98 + getQuoteCount(document?.quotesAndMentions || []) || 0 99 + } 100 + commentsCount={ 101 + getCommentCount(document?.comments_on_documents || []) || 0 102 + } 103 + recommendsCount={document?.recommendsCount || 0} 104 + /> 105 + )} 106 </> 107 } 108 />
+2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 170 preferences: { 171 showComments?: boolean; 172 showMentions?: boolean; 173 showPrevNext?: boolean; 174 }; 175 pubRecord?: NormalizedPublication | null; ··· 233 preferences: { 234 showComments?: boolean; 235 showMentions?: boolean; 236 showPrevNext?: boolean; 237 }; 238 pollData: PollData[];
··· 170 preferences: { 171 showComments?: boolean; 172 showMentions?: boolean; 173 + showRecommends?: boolean; 174 showPrevNext?: boolean; 175 }; 176 pubRecord?: NormalizedPublication | null; ··· 234 preferences: { 235 showComments?: boolean; 236 showMentions?: boolean; 237 + showRecommends?: boolean; 238 showPrevNext?: boolean; 239 }; 240 pollData: PollData[];
+31 -12
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 22 publication_subscriptions(*)) 23 ), 24 document_mentions_in_bsky(*), 25 - leaflets_in_publications(*) 26 `, 27 ) 28 .or(documentUriFilter(did, rkey)) ··· 33 if (!document) return null; 34 35 // Normalize the document record - this is the primary way consumers should access document data 36 - const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 37 if (!normalizedDocument) return null; 38 39 // Normalize the publication record - this is the primary way consumers should access publication data 40 const normalizedPublication = normalizePublicationRecord( 41 - document.documents_in_publications[0]?.publications?.record 42 ); 43 44 // Fetch constellation backlinks for mentions ··· 83 // Filter and sort documents by publishedAt 84 const sortedDocs = allDocs 85 .map((dip) => { 86 - const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 87 return { 88 uri: dip?.documents?.uri, 89 title: normalizedData?.title, ··· 98 ); 99 100 // Find current document index 101 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 102 103 if (currentIndex !== -1) { 104 prevNext = { ··· 122 123 // Build explicit publication context for consumers 124 const rawPub = document.documents_in_publications[0]?.publications; 125 - const publication = rawPub ? { 126 - uri: rawPub.uri, 127 - name: rawPub.name, 128 - identity_did: rawPub.identity_did, 129 - record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, 130 - publication_subscriptions: rawPub.publication_subscriptions || [], 131 - } : null; 132 133 return { 134 ...document, ··· 143 comments: document.comments_on_documents, 144 mentions: document.document_mentions_in_bsky, 145 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 146 }; 147 } 148
··· 22 publication_subscriptions(*)) 23 ), 24 document_mentions_in_bsky(*), 25 + leaflets_in_publications(*), 26 + recommends_on_documents(count) 27 `, 28 ) 29 .or(documentUriFilter(did, rkey)) ··· 34 if (!document) return null; 35 36 // Normalize the document record - this is the primary way consumers should access document data 37 + const normalizedDocument = normalizeDocumentRecord( 38 + document.data, 39 + document.uri, 40 + ); 41 if (!normalizedDocument) return null; 42 43 // Normalize the publication record - this is the primary way consumers should access publication data 44 const normalizedPublication = normalizePublicationRecord( 45 + document.documents_in_publications[0]?.publications?.record, 46 ); 47 48 // Fetch constellation backlinks for mentions ··· 87 // Filter and sort documents by publishedAt 88 const sortedDocs = allDocs 89 .map((dip) => { 90 + const normalizedData = normalizeDocumentRecord( 91 + dip?.documents?.data, 92 + dip?.documents?.uri, 93 + ); 94 return { 95 uri: dip?.documents?.uri, 96 title: normalizedData?.title, ··· 105 ); 106 107 // Find current document index 108 + const currentIndex = sortedDocs.findIndex( 109 + (doc) => doc.uri === document.uri, 110 + ); 111 112 if (currentIndex !== -1) { 113 prevNext = { ··· 131 132 // Build explicit publication context for consumers 133 const rawPub = document.documents_in_publications[0]?.publications; 134 + const publication = rawPub 135 + ? { 136 + uri: rawPub.uri, 137 + name: rawPub.name, 138 + identity_did: rawPub.identity_did, 139 + record: rawPub.record as 140 + | PubLeafletPublication.Record 141 + | SiteStandardPublication.Record 142 + | null, 143 + publication_subscriptions: rawPub.publication_subscriptions || [], 144 + } 145 + : null; 146 + 147 + // Get recommends count from the aggregated query result 148 + const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 149 150 return { 151 ...document, ··· 160 comments: document.comments_on_documents, 161 mentions: document.document_mentions_in_bsky, 162 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 163 + // Recommends data 164 + recommendsCount, 165 }; 166 } 167
+8 -8
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 60 61 function PublishedPostItem(props: { 62 doc: PublishedDocument; 63 - publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 64 pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 showPageBackground: boolean; 66 }) { ··· 94 <div className="flex justify-start align-top flex-row gap-1"> 95 {leaflet && leaflet.permission_tokens && ( 96 <> 97 - <SpeedyLink 98 - className="pt-[6px]" 99 - href={`/${leaflet.leaflet}`} 100 - > 101 <EditTiny /> 102 </SpeedyLink> 103 ··· 129 </div> 130 131 {doc.record.description ? ( 132 - <p className="italic text-secondary"> 133 - {doc.record.description} 134 - </p> 135 ) : null} 136 <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 137 {doc.record.publishedAt ? ( ··· 140 <InteractionPreview 141 quotesCount={doc.mentionsCount} 142 commentsCount={doc.commentsCount} 143 tags={doc.record.tags || []} 144 showComments={pubRecord?.preferences?.showComments !== false} 145 showMentions={pubRecord?.preferences?.showMentions !== false} 146 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 147 /> 148 </div>
··· 60 61 function PublishedPostItem(props: { 62 doc: PublishedDocument; 63 + publication: NonNullable< 64 + NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"] 65 + >; 66 pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 67 showPageBackground: boolean; 68 }) { ··· 96 <div className="flex justify-start align-top flex-row gap-1"> 97 {leaflet && leaflet.permission_tokens && ( 98 <> 99 + <SpeedyLink className="pt-[6px]" href={`/${leaflet.leaflet}`}> 100 <EditTiny /> 101 </SpeedyLink> 102 ··· 128 </div> 129 130 {doc.record.description ? ( 131 + <p className="italic text-secondary">{doc.record.description}</p> 132 ) : null} 133 <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 134 {doc.record.publishedAt ? ( ··· 137 <InteractionPreview 138 quotesCount={doc.mentionsCount} 139 commentsCount={doc.commentsCount} 140 + recommendsCount={doc.recommendsCount} 141 + documentUri={doc.uri} 142 tags={doc.record.tags || []} 143 showComments={pubRecord?.preferences?.showComments !== false} 144 showMentions={pubRecord?.preferences?.showMentions !== false} 145 + showRecommends={pubRecord?.preferences?.showRecommends !== false} 146 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 147 /> 148 </div>
+21 -1
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 29 ? true 30 : record.preferences.showMentions, 31 ); 32 let [showPrevNext, setShowPrevNext] = useState( 33 record?.preferences?.showPrevNext === undefined 34 ? true ··· 53 showComments: showComments, 54 showMentions: showMentions, 55 showPrevNext: showPrevNext, 56 }, 57 }); 58 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 99 <div className="flex flex-col justify-start"> 100 <div className="font-bold">Show Mentions</div> 101 <div className="text-tertiary text-sm leading-tight"> 102 - Display a list of posts on Bluesky that mention your post 103 </div> 104 </div> 105 </Toggle>
··· 29 ? true 30 : record.preferences.showMentions, 31 ); 32 + let [showRecommends, setShowRecommends] = useState( 33 + record?.preferences?.showRecommends === undefined 34 + ? true 35 + : record.preferences.showRecommends, 36 + ); 37 let [showPrevNext, setShowPrevNext] = useState( 38 record?.preferences?.showPrevNext === undefined 39 ? true ··· 58 showComments: showComments, 59 showMentions: showMentions, 60 showPrevNext: showPrevNext, 61 + showRecommends: showRecommends, 62 }, 63 }); 64 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 105 <div className="flex flex-col justify-start"> 106 <div className="font-bold">Show Mentions</div> 107 <div className="text-tertiary text-sm leading-tight"> 108 + Display a list Bluesky mentions about your post 109 + </div> 110 + </div> 111 + </Toggle> 112 + 113 + <Toggle 114 + toggle={showRecommends} 115 + onToggle={() => { 116 + setShowRecommends(!showRecommends); 117 + }} 118 + > 119 + <div className="flex flex-col justify-start"> 120 + <div className="font-bold">Show Recommends</div> 121 + <div className="text-tertiary text-sm leading-tight"> 122 + Allow readers to recommend/like your post 123 </div> 124 </div> 125 </Toggle>
+14 -8
app/lish/[did]/[publication]/page.tsx
··· 38 documents_in_publications(documents( 39 *, 40 comments_on_documents(count), 41 - document_mentions_in_bsky(count) 42 )) 43 `, 44 ) ··· 119 }) 120 .map((doc) => { 121 if (!doc.documents) return null; 122 - const doc_record = normalizeDocumentRecord(doc.documents.data); 123 if (!doc_record) return null; 124 let uri = new AtUri(doc.documents.uri); 125 let quotes = ··· 128 record?.preferences?.showComments === false 129 ? 0 130 : doc.documents.comments_on_documents[0].count || 0; 131 let tags = doc_record.tags || []; 132 133 return ( ··· 143 </p> 144 </SpeedyLink> 145 146 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 147 <p className="text-sm text-tertiary "> 148 {doc_record.publishedAt && ( 149 <LocalizedDate ··· 156 /> 157 )}{" "} 158 </p> 159 - {comments > 0 || quotes > 0 || tags.length > 0 ? ( 160 - <Separator classname="h-4! mx-1" /> 161 - ) : ( 162 - "" 163 - )} 164 <InteractionPreview 165 quotesCount={quotes} 166 commentsCount={comments} 167 tags={tags} 168 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 showComments={ ··· 171 } 172 showMentions={ 173 record?.preferences?.showMentions !== false 174 } 175 /> 176 </div>
··· 38 documents_in_publications(documents( 39 *, 40 comments_on_documents(count), 41 + document_mentions_in_bsky(count), 42 + recommends_on_documents(count) 43 )) 44 `, 45 ) ··· 120 }) 121 .map((doc) => { 122 if (!doc.documents) return null; 123 + const doc_record = normalizeDocumentRecord( 124 + doc.documents.data, 125 + ); 126 if (!doc_record) return null; 127 let uri = new AtUri(doc.documents.uri); 128 let quotes = ··· 131 record?.preferences?.showComments === false 132 ? 0 133 : doc.documents.comments_on_documents[0].count || 0; 134 + let recommends = 135 + doc.documents.recommends_on_documents?.[0]?.count || 0; 136 let tags = doc_record.tags || []; 137 138 return ( ··· 148 </p> 149 </SpeedyLink> 150 151 + <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 152 <p className="text-sm text-tertiary "> 153 {doc_record.publishedAt && ( 154 <LocalizedDate ··· 161 /> 162 )}{" "} 163 </p> 164 + 165 <InteractionPreview 166 quotesCount={quotes} 167 commentsCount={comments} 168 + recommendsCount={recommends} 169 + documentUri={doc.documents.uri} 170 tags={tags} 171 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 172 showComments={ ··· 174 } 175 showMentions={ 176 record?.preferences?.showMentions !== false 177 + } 178 + showRecommends={ 179 + record?.preferences?.showRecommends !== false 180 } 181 /> 182 </div>
+1
app/lish/createPub/CreatePubForm.tsx
··· 58 showComments: true, 59 showMentions: true, 60 showPrevNext: true, 61 }, 62 }); 63
··· 58 showComments: true, 59 showMentions: true, 60 showPrevNext: true, 61 + showRecommends: true, 62 }, 63 }); 64
+1 -2
app/lish/createPub/UpdatePubForm.tsx
··· 88 showComments: showComments, 89 showMentions: showMentions, 90 showPrevNext: showPrevNext, 91 }, 92 }); 93 toast({ type: "success", content: "Updated!" }); ··· 194 </p> 195 </div> 196 </Toggle> 197 - 198 - 199 </div> 200 </form> 201 );
··· 88 showComments: showComments, 89 showMentions: showMentions, 90 showPrevNext: showPrevNext, 91 + showRecommends: record?.preferences?.showRecommends ?? true, 92 }, 93 }); 94 toast({ type: "success", content: "Updated!" }); ··· 195 </p> 196 </div> 197 </Toggle> 198 </div> 199 </form> 200 );
+23 -9
app/lish/createPub/createPublication.ts
··· 5 PubLeafletPublication, 6 SiteStandardPublication, 7 } from "lexicons/api"; 8 - import { 9 - restoreOAuthSession, 10 - OAuthSessionError, 11 - } from "src/atproto-oauth"; 12 import { getIdentityData } from "actions/getIdentityData"; 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { Json } from "supabase/database.types"; ··· 76 77 // Build record based on publication type 78 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 - let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 80 81 // Upload the icon if provided 82 if (iconFile && iconFile.size > 0) { ··· 97 ...(iconBlob && { icon: iconBlob }), 98 basicTheme: { 99 $type: "site.standard.theme.basic", 100 - background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, 101 - foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, 102 - accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, 103 - accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, 104 }, 105 preferences: { 106 showInDiscover: preferences.showInDiscover, 107 showComments: preferences.showComments, 108 showMentions: preferences.showMentions, 109 showPrevNext: preferences.showPrevNext, 110 }, 111 } satisfies SiteStandardPublication.Record; 112 } else {
··· 5 PubLeafletPublication, 6 SiteStandardPublication, 7 } from "lexicons/api"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; ··· 73 74 // Build record based on publication type 75 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 76 + let iconBlob: 77 + | Awaited< 78 + ReturnType<typeof agent.com.atproto.repo.uploadBlob> 79 + >["data"]["blob"] 80 + | undefined; 81 82 // Upload the icon if provided 83 if (iconFile && iconFile.size > 0) { ··· 98 ...(iconBlob && { icon: iconBlob }), 99 basicTheme: { 100 $type: "site.standard.theme.basic", 101 + background: { 102 + $type: "site.standard.theme.color#rgb", 103 + ...PubThemeDefaultsRGB.background, 104 + }, 105 + foreground: { 106 + $type: "site.standard.theme.color#rgb", 107 + ...PubThemeDefaultsRGB.foreground, 108 + }, 109 + accent: { 110 + $type: "site.standard.theme.color#rgb", 111 + ...PubThemeDefaultsRGB.accent, 112 + }, 113 + accentForeground: { 114 + $type: "site.standard.theme.color#rgb", 115 + ...PubThemeDefaultsRGB.accentForeground, 116 + }, 117 }, 118 preferences: { 119 showInDiscover: preferences.showInDiscover, 120 showComments: preferences.showComments, 121 showMentions: preferences.showMentions, 122 showPrevNext: preferences.showPrevNext, 123 + showRecommends: preferences.showRecommends, 124 }, 125 } satisfies SiteStandardPublication.Record; 126 } else {
+167 -98
app/lish/createPub/updatePublication.ts
··· 77 } 78 79 const aturi = new AtUri(existingPub.uri); 80 - const publicationType = getPublicationType(aturi.collection) as PublicationType; 81 82 // Normalize existing record 83 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 128 } 129 130 /** Merges override with existing value, respecting explicit undefined */ 131 - function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 132 return hasOverride ? override : existing; 133 } 134 ··· 146 return { 147 $type: "pub.leaflet.publication", 148 name: overrides.name ?? normalizedPub?.name ?? "", 149 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 152 base_path: overrides.basePath ?? existingBasePath, 153 - preferences: preferences ? { 154 - $type: "pub.leaflet.publication#preferences", 155 - showInDiscover: preferences.showInDiscover, 156 - showComments: preferences.showComments, 157 - showMentions: preferences.showMentions, 158 - showPrevNext: preferences.showPrevNext, 159 - } : undefined, 160 }; 161 } 162 ··· 175 return { 176 $type: "site.standard.publication", 177 name: overrides.name ?? normalizedPub?.name ?? "", 178 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 - basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 182 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 - preferences: preferences ? { 184 - showInDiscover: preferences.showInDiscover, 185 - showComments: preferences.showComments, 186 - showMentions: preferences.showMentions, 187 - showPrevNext: preferences.showPrevNext, 188 - } : undefined, 189 }; 190 } 191 ··· 217 iconFile?: File | null; 218 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 }): Promise<UpdatePublicationResult> { 220 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 - // Upload icon if provided 222 - let iconBlob = normalizedPub?.icon; 223 - if (iconFile && iconFile.size > 0) { 224 - const buffer = await iconFile.arrayBuffer(); 225 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 - new Uint8Array(buffer), 227 - { encoding: iconFile.type }, 228 - ); 229 - if (uploadResult.data.blob) { 230 - iconBlob = uploadResult.data.blob; 231 } 232 - } 233 234 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 - name, 236 - description, 237 - icon: iconBlob, 238 - preferences, 239 - }); 240 - }); 241 } 242 243 export async function updatePublicationBasePath({ ··· 247 uri: string; 248 base_path: string; 249 }): Promise<UpdatePublicationResult> { 250 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 - basePath: base_path, 253 - }); 254 - }); 255 } 256 257 type Color = ··· 275 accentText: Color; 276 }; 277 }): Promise<UpdatePublicationResult> { 278 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 - // Build theme object 280 - const themeData = { 281 - $type: "pub.leaflet.publication#theme" as const, 282 - backgroundImage: theme.backgroundImage 283 - ? { 284 - $type: "pub.leaflet.theme.backgroundImage", 285 - image: ( 286 - await agent.com.atproto.repo.uploadBlob( 287 - new Uint8Array(await theme.backgroundImage.arrayBuffer()), 288 - { encoding: theme.backgroundImage.type }, 289 - ) 290 - )?.data.blob, 291 - width: theme.backgroundRepeat || undefined, 292 - repeat: !!theme.backgroundRepeat, 293 - } 294 - : theme.backgroundImage === null 295 - ? undefined 296 - : normalizedPub?.theme?.backgroundImage, 297 - backgroundColor: theme.backgroundColor 298 - ? { 299 - ...theme.backgroundColor, 300 - } 301 - : undefined, 302 - pageWidth: theme.pageWidth, 303 - primary: { 304 - ...theme.primary, 305 - }, 306 - pageBackground: { 307 - ...theme.pageBackground, 308 - }, 309 - showPageBackground: theme.showPageBackground, 310 - accentBackground: { 311 - ...theme.accentBackground, 312 - }, 313 - accentText: { 314 - ...theme.accentText, 315 - }, 316 - }; 317 318 - // Derive basicTheme from the theme colors for site.standard.publication 319 - const basicTheme: NormalizedPublication["basicTheme"] = { 320 - $type: "site.standard.theme.basic", 321 - background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 322 - foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 323 - accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 324 - accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 325 - }; 326 327 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 - theme: themeData, 329 - basicTheme, 330 - }); 331 - }); 332 }
··· 77 } 78 79 const aturi = new AtUri(existingPub.uri); 80 + const publicationType = getPublicationType( 81 + aturi.collection, 82 + ) as PublicationType; 83 84 // Normalize existing record 85 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 130 } 131 132 /** Merges override with existing value, respecting explicit undefined */ 133 + function resolveField<T>( 134 + override: T | undefined, 135 + existing: T | undefined, 136 + hasOverride: boolean, 137 + ): T | undefined { 138 return hasOverride ? override : existing; 139 } 140 ··· 152 return { 153 $type: "pub.leaflet.publication", 154 name: overrides.name ?? normalizedPub?.name ?? "", 155 + description: resolveField( 156 + overrides.description, 157 + normalizedPub?.description, 158 + "description" in overrides, 159 + ), 160 + icon: resolveField( 161 + overrides.icon, 162 + normalizedPub?.icon, 163 + "icon" in overrides, 164 + ), 165 + theme: resolveField( 166 + overrides.theme, 167 + normalizedPub?.theme, 168 + "theme" in overrides, 169 + ), 170 base_path: overrides.basePath ?? existingBasePath, 171 + preferences: preferences 172 + ? { 173 + $type: "pub.leaflet.publication#preferences", 174 + showInDiscover: preferences.showInDiscover, 175 + showComments: preferences.showComments, 176 + showMentions: preferences.showMentions, 177 + showPrevNext: preferences.showPrevNext, 178 + showRecommends: preferences.showRecommends, 179 + } 180 + : undefined, 181 }; 182 } 183 ··· 196 return { 197 $type: "site.standard.publication", 198 name: overrides.name ?? normalizedPub?.name ?? "", 199 + description: resolveField( 200 + overrides.description, 201 + normalizedPub?.description, 202 + "description" in overrides, 203 + ), 204 + icon: resolveField( 205 + overrides.icon, 206 + normalizedPub?.icon, 207 + "icon" in overrides, 208 + ), 209 + theme: resolveField( 210 + overrides.theme, 211 + normalizedPub?.theme, 212 + "theme" in overrides, 213 + ), 214 + basicTheme: resolveField( 215 + overrides.basicTheme, 216 + normalizedPub?.basicTheme, 217 + "basicTheme" in overrides, 218 + ), 219 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 220 + preferences: preferences 221 + ? { 222 + showInDiscover: preferences.showInDiscover, 223 + showComments: preferences.showComments, 224 + showMentions: preferences.showMentions, 225 + showPrevNext: preferences.showPrevNext, 226 + showRecommends: preferences.showRecommends, 227 + } 228 + : undefined, 229 }; 230 } 231 ··· 257 iconFile?: File | null; 258 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 259 }): Promise<UpdatePublicationResult> { 260 + return withPublicationUpdate( 261 + uri, 262 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 263 + // Upload icon if provided 264 + let iconBlob = normalizedPub?.icon; 265 + if (iconFile && iconFile.size > 0) { 266 + const buffer = await iconFile.arrayBuffer(); 267 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 268 + new Uint8Array(buffer), 269 + { encoding: iconFile.type }, 270 + ); 271 + if (uploadResult.data.blob) { 272 + iconBlob = uploadResult.data.blob; 273 + } 274 } 275 276 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 277 + name, 278 + description, 279 + icon: iconBlob, 280 + preferences, 281 + }); 282 + }, 283 + ); 284 } 285 286 export async function updatePublicationBasePath({ ··· 290 uri: string; 291 base_path: string; 292 }): Promise<UpdatePublicationResult> { 293 + return withPublicationUpdate( 294 + uri, 295 + async ({ normalizedPub, existingBasePath, publicationType }) => { 296 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 297 + basePath: base_path, 298 + }); 299 + }, 300 + ); 301 } 302 303 type Color = ··· 321 accentText: Color; 322 }; 323 }): Promise<UpdatePublicationResult> { 324 + return withPublicationUpdate( 325 + uri, 326 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 327 + // Build theme object 328 + const themeData = { 329 + $type: "pub.leaflet.publication#theme" as const, 330 + backgroundImage: theme.backgroundImage 331 + ? { 332 + $type: "pub.leaflet.theme.backgroundImage", 333 + image: ( 334 + await agent.com.atproto.repo.uploadBlob( 335 + new Uint8Array(await theme.backgroundImage.arrayBuffer()), 336 + { encoding: theme.backgroundImage.type }, 337 + ) 338 + )?.data.blob, 339 + width: theme.backgroundRepeat || undefined, 340 + repeat: !!theme.backgroundRepeat, 341 + } 342 + : theme.backgroundImage === null 343 + ? undefined 344 + : normalizedPub?.theme?.backgroundImage, 345 + backgroundColor: theme.backgroundColor 346 + ? { 347 + ...theme.backgroundColor, 348 + } 349 + : undefined, 350 + pageWidth: theme.pageWidth, 351 + primary: { 352 + ...theme.primary, 353 + }, 354 + pageBackground: { 355 + ...theme.pageBackground, 356 + }, 357 + showPageBackground: theme.showPageBackground, 358 + accentBackground: { 359 + ...theme.accentBackground, 360 + }, 361 + accentText: { 362 + ...theme.accentText, 363 + }, 364 + }; 365 366 + // Derive basicTheme from the theme colors for site.standard.publication 367 + const basicTheme: NormalizedPublication["basicTheme"] = { 368 + $type: "site.standard.theme.basic", 369 + background: { 370 + $type: "site.standard.theme.color#rgb", 371 + r: theme.backgroundColor.r, 372 + g: theme.backgroundColor.g, 373 + b: theme.backgroundColor.b, 374 + }, 375 + foreground: { 376 + $type: "site.standard.theme.color#rgb", 377 + r: theme.primary.r, 378 + g: theme.primary.g, 379 + b: theme.primary.b, 380 + }, 381 + accent: { 382 + $type: "site.standard.theme.color#rgb", 383 + r: theme.accentBackground.r, 384 + g: theme.accentBackground.g, 385 + b: theme.accentBackground.b, 386 + }, 387 + accentForeground: { 388 + $type: "site.standard.theme.color#rgb", 389 + r: theme.accentText.r, 390 + g: theme.accentText.g, 391 + b: theme.accentText.b, 392 + }, 393 + }; 394 395 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 396 + theme: themeData, 397 + basicTheme, 398 + }); 399 + }, 400 + ); 401 }
+18 -3
components/Canvas.tsx
··· 19 import { Separator } from "./Layout"; 20 import { CommentTiny } from "./Icons/CommentTiny"; 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 - import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 27 export function Canvas(props: { 28 entityID: string; ··· 168 if (!normalizedPublication) return null; 169 let showComments = normalizedPublication.preferences?.showComments !== false; 170 let showMentions = normalizedPublication.preferences?.showMentions !== false; 171 172 return ( 173 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 174 {showComments && ( 175 <div className="flex gap-1 text-tertiary items-center"> 176 <CommentTiny className="text-border" /> โ€” 177 </div> 178 )} 179 - {showComments && ( 180 <div className="flex gap-1 text-tertiary items-center"> 181 <QuoteTiny className="text-border" /> โ€” 182 </div> 183 )} 184 185 {!props.isSubpage && ( 186 <> 187 <Separator classname="h-5" /> ··· 191 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 trigger={<InfoSmall />} 193 > 194 - <PublicationMetadata /> 195 </Popover> 196 </> 197 )}
··· 19 import { Separator } from "./Layout"; 20 import { CommentTiny } from "./Icons/CommentTiny"; 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 + import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 + import { RecommendTinyEmpty } from "./Icons/RecommendTiny"; 27 28 export function Canvas(props: { 29 entityID: string; ··· 169 if (!normalizedPublication) return null; 170 let showComments = normalizedPublication.preferences?.showComments !== false; 171 let showMentions = normalizedPublication.preferences?.showMentions !== false; 172 + let showRecommends = 173 + normalizedPublication.preferences?.showRecommends !== false; 174 175 return ( 176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 177 + {showRecommends && ( 178 + <div className="flex gap-1 text-tertiary items-center"> 179 + <RecommendTinyEmpty className="text-border" /> โ€” 180 + </div> 181 + )} 182 {showComments && ( 183 <div className="flex gap-1 text-tertiary items-center"> 184 <CommentTiny className="text-border" /> โ€” 185 </div> 186 )} 187 + {showMentions && ( 188 <div className="flex gap-1 text-tertiary items-center"> 189 <QuoteTiny className="text-border" /> โ€” 190 </div> 191 )} 192 193 + {showMentions !== false || 194 + showComments !== false || 195 + showRecommends === false ? ( 196 + <Separator classname="h-4!" /> 197 + ) : null} 198 + <AddTags /> 199 + 200 {!props.isSubpage && ( 201 <> 202 <Separator classname="h-5" /> ··· 206 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 207 trigger={<InfoSmall />} 208 > 209 + <PublicationMetadata noInteractions /> 210 </Popover> 211 </> 212 )}
+37
components/Icons/RecommendTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const RecommendTinyFilled = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M13.8218 8.85542C13.9838 8.63176 14.2964 8.58118 14.5201 8.74312C14.7433 8.90516 14.7932 9.21786 14.6314 9.44136C12.9671 11.7399 10.7811 13.1142 9.07472 14.0947C8.83547 14.2321 8.52981 14.1491 8.3921 13.9101C8.25463 13.6707 8.33728 13.365 8.57667 13.2275C10.2589 12.2608 12.2881 10.9736 13.8218 8.85542ZM9.09327 2.90525C10.0113 2.2003 11.4161 2.21431 12.2886 2.61521C13.0365 2.95905 13.6929 3.5946 14.0044 4.62106C14.2614 5.46809 14.2169 6.28576 14.0044 7.17867C13.4531 9.49467 10.1475 11.7776 8.22413 12.8828C8.15152 12.9245 8.05431 12.9453 7.97315 12.9453C7.89219 12.9453 7.80343 12.9243 7.73096 12.8828C5.80749 11.7776 2.50174 9.49385 1.95065 7.1777C1.7383 6.28491 1.69376 5.46798 1.95065 4.62106C2.26221 3.59471 2.91764 2.95906 3.66551 2.61521C4.53812 2.21415 5.94374 2.19992 6.86181 2.90525C7.4145 3.32999 7.72613 3.72603 7.97315 4.14939C8.22018 3.72604 8.5406 3.32998 9.09327 2.90525ZM4.55418 3.84079C4.44015 3.58958 4.14441 3.47805 3.89305 3.59177C2.93793 4.0246 2.4787 5.35564 2.85105 6.64059C2.9282 6.90532 3.20525 7.05713 3.47019 6.98043C3.73523 6.9035 3.88869 6.62638 3.81199 6.36129C3.52801 5.38087 3.94973 4.66317 4.30516 4.50192C4.55654 4.38789 4.6681 4.09224 4.55418 3.84079Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + }; 20 + 21 + export const RecommendTinyEmpty = (props: Props) => { 22 + return ( 23 + <svg 24 + width="16" 25 + height="16" 26 + viewBox="0 0 16 16" 27 + fill="none" 28 + xmlns="http://www.w3.org/2000/svg" 29 + {...props} 30 + > 31 + <path 32 + d="M13.8215 8.85505C13.9834 8.63149 14.2961 8.58084 14.5197 8.74275C14.7432 8.90468 14.7928 9.21739 14.631 9.44099C12.9668 11.7395 10.7808 13.1138 9.0744 14.0943C8.83501 14.2318 8.52937 14.1491 8.39178 13.9097C8.25431 13.6703 8.33696 13.3647 8.57635 13.2271C10.2586 12.2605 12.2878 10.9733 13.8215 8.85505ZM4.12127 2.44392C5.05035 2.20462 6.17272 2.3143 7.04412 3.04744C7.33889 3.29547 7.62399 3.64884 7.85369 3.96833C7.89451 4.02512 7.93345 4.08237 7.97186 4.13826C8.22436 3.76381 8.53885 3.3457 8.86248 3.06501C9.80388 2.24888 11.1891 2.16939 12.1564 2.56501C12.9693 2.89763 13.663 3.49593 14.0002 4.60701C14.267 5.48669 14.2598 6.26139 14.0461 7.15974C13.7527 8.39225 12.7396 9.53682 11.6691 10.4703C10.5802 11.4198 9.3429 12.2265 8.47772 12.7681C8.47247 12.7714 8.46646 12.7748 8.46111 12.7779C8.43136 12.795 8.369 12.8315 8.30096 12.8619C8.2405 12.8889 8.11991 12.937 7.97576 12.9371C7.82229 12.9372 7.7007 12.8832 7.63885 12.8521C7.6045 12.8349 7.57372 12.8176 7.55291 12.8052C7.52605 12.7893 7.52018 12.7855 7.50701 12.7779C7.50235 12.7752 7.49792 12.7719 7.49334 12.7691C6.59506 12.2129 5.35778 11.3987 4.27654 10.4439C3.21273 9.50447 2.21958 8.35999 1.92693 7.13044C1.71321 6.23218 1.70502 5.4352 1.97186 4.55525C2.31285 3.43128 3.22341 2.67532 4.12127 2.44392ZM6.40057 3.81306C5.82954 3.33259 5.06002 3.23404 4.37029 3.41169C3.79433 3.56026 3.16381 4.07131 2.92889 4.84529C2.72085 5.53135 2.72051 6.14631 2.89959 6.899C3.11654 7.81042 3.90364 8.77988 4.93865 9.69392C5.94258 10.5805 7.10507 11.3507 7.98358 11.8961C8.83657 11.3611 9.99989 10.5988 11.0119 9.71638C12.0571 8.8049 12.8571 7.83679 13.0734 6.9283C13.2523 6.17606 13.2512 5.58411 13.0431 4.89802C12.8044 4.11102 12.3496 3.72381 11.7775 3.48982C11.1013 3.21328 10.1298 3.29025 9.51776 3.82087C9.10331 4.18037 8.63998 4.9218 8.40545 5.3238C8.3158 5.4772 8.1515 5.57185 7.97381 5.57185C7.79617 5.57171 7.63172 5.47723 7.54217 5.3238C7.43363 5.13777 7.25216 4.84479 7.04119 4.55134C6.82572 4.25167 6.5988 3.97993 6.40057 3.81306Z" 33 + fill="currentColor" 34 + /> 35 + </svg> 36 + ); 37 + };
+20 -14
components/InteractionsPreview.tsx
··· 7 import { Popover } from "./Popover"; 8 import { TagTiny } from "./Icons/TagTiny"; 9 import { SpeedyLink } from "./SpeedyLink"; 10 11 export const InteractionPreview = (props: { 12 quotesCount: number; 13 commentsCount: number; 14 tags?: string[]; 15 postUrl: string; 16 showComments: boolean; 17 showMentions: boolean; 18 19 share?: boolean; 20 }) => { 21 let smoker = useSmoker(); 22 let interactionsAvailable = 23 (props.quotesCount > 0 && props.showMentions) || 24 - (props.showComments !== false && props.commentsCount > 0); 25 26 const tagsCount = props.tags?.length || 0; 27 28 return ( 29 - <div 30 - className={`flex gap-2 text-tertiary text-sm items-center self-start`} 31 - > 32 - {tagsCount === 0 ? null : ( 33 - <> 34 - <TagPopover tags={props.tags!} /> 35 - {interactionsAvailable || props.share ? ( 36 - <Separator classname="h-4!" /> 37 - ) : null} 38 - </> 39 )} 40 41 {!props.showMentions || props.quotesCount === 0 ? null : ( ··· 56 <CommentTiny /> {props.commentsCount} 57 </SpeedyLink> 58 )} 59 - {interactionsAvailable && props.share ? ( 60 - <Separator classname="h-4! !min-h-0" /> 61 - ) : null} 62 {props.share && ( 63 <> 64 <button 65 id={`copy-post-link-${props.postUrl}`} 66 className="flex gap-1 items-center hover:text-accent-contrast relative"
··· 7 import { Popover } from "./Popover"; 8 import { TagTiny } from "./Icons/TagTiny"; 9 import { SpeedyLink } from "./SpeedyLink"; 10 + import { RecommendButton } from "./RecommendButton"; 11 12 export const InteractionPreview = (props: { 13 quotesCount: number; 14 commentsCount: number; 15 + recommendsCount: number; 16 + documentUri: string; 17 tags?: string[]; 18 postUrl: string; 19 showComments: boolean; 20 showMentions: boolean; 21 + showRecommends: boolean; 22 23 share?: boolean; 24 }) => { 25 let smoker = useSmoker(); 26 let interactionsAvailable = 27 (props.quotesCount > 0 && props.showMentions) || 28 + (props.showComments !== false && props.commentsCount > 0) || 29 + (props.showRecommends !== false && props.recommendsCount > 0); 30 31 const tagsCount = props.tags?.length || 0; 32 33 return ( 34 + <div className={`flex gap-2 text-tertiary text-sm items-center`}> 35 + {props.showRecommends === false ? null : ( 36 + <RecommendButton 37 + documentUri={props.documentUri} 38 + recommendsCount={props.recommendsCount} 39 + /> 40 )} 41 42 {!props.showMentions || props.quotesCount === 0 ? null : ( ··· 57 <CommentTiny /> {props.commentsCount} 58 </SpeedyLink> 59 )} 60 + {tagsCount === 0 ? null : ( 61 + <> 62 + {interactionsAvailable ? <Separator classname="h-4!" /> : null} 63 + <TagPopover tags={props.tags!} /> 64 + </> 65 + )} 66 {props.share && ( 67 <> 68 + <Separator classname="h-4!" /> 69 + 70 <button 71 id={`copy-post-link-${props.postUrl}`} 72 className="flex gap-1 items-center hover:text-accent-contrast relative"
+43 -25
components/Pages/PublicationMetadata.tsx
··· 20 import { useIdentityData } from "components/IdentityProvider"; 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22 import { Backdater } from "./Backdater"; 23 24 - export const PublicationMetadata = () => { 25 let { rep } = useReplicache(); 26 - let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 27 let { identity } = useIdentityData(); 28 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 29 let description = useSubscribe(rep, (tx) => ··· 114 ) : ( 115 <p>Draft</p> 116 )} 117 - <div className="flex gap-2 text-border items-center"> 118 - {tags && ( 119 - <> 120 - <AddTags /> 121 - {normalizedPublication?.preferences?.showMentions !== false || 122 - normalizedPublication?.preferences?.showComments !== false ? ( 123 - <Separator classname="h-4!" /> 124 - ) : null} 125 - </> 126 - )} 127 - {normalizedPublication?.preferences?.showMentions !== false && ( 128 - <div className="flex gap-1 items-center"> 129 - <QuoteTiny />โ€” 130 - </div> 131 - )} 132 - {normalizedPublication?.preferences?.showComments !== false && ( 133 - <div className="flex gap-1 items-center"> 134 - <CommentTiny />โ€” 135 - </div> 136 - )} 137 - </div> 138 </> 139 } 140 /> ··· 238 ); 239 }; 240 241 - const AddTags = () => { 242 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 243 let { rep } = useReplicache(); 244 ··· 251 let tags: string[] = []; 252 if (Array.isArray(replicacheTags)) { 253 tags = replicacheTags; 254 - } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 255 tags = normalizedDocument.tags as string[]; 256 } 257
··· 20 import { useIdentityData } from "components/IdentityProvider"; 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22 import { Backdater } from "./Backdater"; 23 + import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; 24 25 + export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 26 let { rep } = useReplicache(); 27 + let { 28 + data: pub, 29 + normalizedDocument, 30 + normalizedPublication, 31 + } = useLeafletPublicationData(); 32 let { identity } = useIdentityData(); 33 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 34 let description = useSubscribe(rep, (tx) => ··· 119 ) : ( 120 <p>Draft</p> 121 )} 122 + {!props.noInteractions && ( 123 + <div className="flex gap-2 text-border items-center"> 124 + {normalizedPublication?.preferences?.showRecommends !== false && ( 125 + <div className="flex gap-1 items-center"> 126 + <RecommendTinyEmpty />โ€” 127 + </div> 128 + )} 129 + 130 + {normalizedPublication?.preferences?.showMentions !== false && ( 131 + <div className="flex gap-1 items-center"> 132 + <QuoteTiny />โ€” 133 + </div> 134 + )} 135 + {normalizedPublication?.preferences?.showComments !== false && ( 136 + <div className="flex gap-1 items-center"> 137 + <CommentTiny />โ€” 138 + </div> 139 + )} 140 + {tags && ( 141 + <> 142 + {normalizedPublication?.preferences?.showRecommends !== 143 + false || 144 + normalizedPublication?.preferences?.showMentions !== false || 145 + normalizedPublication?.preferences?.showComments !== false ? ( 146 + <Separator classname="h-4!" /> 147 + ) : null} 148 + <AddTags /> 149 + </> 150 + )} 151 + </div> 152 + )} 153 </> 154 } 155 /> ··· 253 ); 254 }; 255 256 + export const AddTags = () => { 257 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 258 let { rep } = useReplicache(); 259 ··· 266 let tags: string[] = []; 267 if (Array.isArray(replicacheTags)) { 268 tags = replicacheTags; 269 + } else if ( 270 + normalizedDocument?.tags && 271 + Array.isArray(normalizedDocument.tags) 272 + ) { 273 tags = normalizedDocument.tags as string[]; 274 } 275
+9 -1
components/PostListing.tsx
··· 53 pubRecord?.preferences?.showComments === false 54 ? 0 55 : props.documents.comments_on_documents?.[0]?.count || 0; 56 let tags = (postRecord?.tags as string[] | undefined) || []; 57 58 // For standalone posts, link directly to the document ··· 88 > 89 <h3 className="text-primary truncate">{postRecord.title}</h3> 90 91 - <p className="text-secondary italic">{postRecord.description}</p> 92 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 93 {props.publication && pubRecord && ( 94 <PubInfo ··· 103 postUrl={postHref} 104 quotesCount={quotes} 105 commentsCount={comments} 106 tags={tags} 107 showComments={pubRecord?.preferences?.showComments !== false} 108 showMentions={pubRecord?.preferences?.showMentions !== false} 109 share 110 /> 111 </div>
··· 53 pubRecord?.preferences?.showComments === false 54 ? 0 55 : props.documents.comments_on_documents?.[0]?.count || 0; 56 + let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 57 let tags = (postRecord?.tags as string[] | undefined) || []; 58 59 // For standalone posts, link directly to the document ··· 89 > 90 <h3 className="text-primary truncate">{postRecord.title}</h3> 91 92 + <p className="text-secondary italic line-clamp-3"> 93 + {postRecord.description} 94 + </p> 95 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 96 {props.publication && pubRecord && ( 97 <PubInfo ··· 106 postUrl={postHref} 107 quotesCount={quotes} 108 commentsCount={comments} 109 + recommendsCount={recommends} 110 + documentUri={props.documents.uri} 111 tags={tags} 112 showComments={pubRecord?.preferences?.showComments !== false} 113 showMentions={pubRecord?.preferences?.showMentions !== false} 114 + showRecommends={ 115 + pubRecord?.preferences?.showRecommends !== false 116 + } 117 share 118 /> 119 </div>
+173
components/RecommendButton.tsx
···
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import useSWR, { mutate } from "swr"; 5 + import { create, windowScheduler } from "@yornaath/batshit"; 6 + import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny"; 7 + import { 8 + recommendAction, 9 + unrecommendAction, 10 + } from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; 11 + import { callRPC } from "app/api/rpc/client"; 12 + import { useSmoker, useToaster } from "./Toast"; 13 + import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError"; 14 + import { ButtonSecondary } from "./Buttons"; 15 + import { Separator } from "./Layout"; 16 + 17 + // Create a batcher for recommendation checks 18 + // Batches requests made within 10ms window 19 + const recommendationBatcher = create({ 20 + fetcher: async (documentUris: string[]) => { 21 + const response = await callRPC("get_user_recommendations", { 22 + documentUris, 23 + }); 24 + return response.result; 25 + }, 26 + resolver: (results, documentUri) => results[documentUri] ?? false, 27 + scheduler: windowScheduler(10), 28 + }); 29 + 30 + const getRecommendationKey = (documentUri: string) => 31 + `recommendation:${documentUri}`; 32 + 33 + function useUserRecommendation(documentUri: string) { 34 + const { data: hasRecommended, isLoading } = useSWR( 35 + getRecommendationKey(documentUri), 36 + () => recommendationBatcher.fetch(documentUri), 37 + ); 38 + 39 + return { 40 + hasRecommended: hasRecommended ?? false, 41 + isLoading, 42 + }; 43 + } 44 + 45 + function mutateRecommendation(documentUri: string, hasRecommended: boolean) { 46 + mutate(getRecommendationKey(documentUri), hasRecommended, { 47 + revalidate: false, 48 + }); 49 + } 50 + 51 + /** 52 + * RecommendButton that fetches the user's recommendation status asynchronously. 53 + * Uses SWR with batched requests for efficient fetching when many buttons are rendered. 54 + */ 55 + export function RecommendButton(props: { 56 + documentUri: string; 57 + recommendsCount: number; 58 + className?: string; 59 + expanded?: boolean; 60 + }) { 61 + const { hasRecommended, isLoading } = useUserRecommendation( 62 + props.documentUri, 63 + ); 64 + const [count, setCount] = useState(props.recommendsCount); 65 + const [isPending, setIsPending] = useState(false); 66 + const [optimisticRecommended, setOptimisticRecommended] = useState< 67 + boolean | null 68 + >(null); 69 + const toaster = useToaster(); 70 + const smoker = useSmoker(); 71 + 72 + // Use optimistic state if set, otherwise use fetched state 73 + const displayRecommended = 74 + optimisticRecommended !== null ? optimisticRecommended : hasRecommended; 75 + 76 + const handleClick = async (e: React.MouseEvent) => { 77 + if (isPending || isLoading) return; 78 + 79 + const currentlyRecommended = displayRecommended; 80 + setIsPending(true); 81 + setOptimisticRecommended(!currentlyRecommended); 82 + setCount((c) => (currentlyRecommended ? c - 1 : c + 1)); 83 + 84 + if (!currentlyRecommended) { 85 + smoker({ 86 + position: { 87 + x: e.clientX, 88 + y: e.clientY - 16, 89 + }, 90 + text: <div className="text-xs">Recc'd!</div>, 91 + }); 92 + } 93 + 94 + const result = currentlyRecommended 95 + ? await unrecommendAction({ document: props.documentUri }) 96 + : await recommendAction({ document: props.documentUri }); 97 + if (!result.success) { 98 + // Revert optimistic update 99 + setOptimisticRecommended(null); 100 + setCount((c) => (currentlyRecommended ? c + 1 : c - 1)); 101 + setIsPending(false); 102 + 103 + toaster({ 104 + content: isOAuthSessionError(result.error) ? ( 105 + <OAuthErrorMessage error={result.error} /> 106 + ) : ( 107 + "oh no! error!" 108 + ), 109 + type: "error", 110 + }); 111 + return; 112 + } 113 + 114 + // Update the SWR cache to match the new state 115 + mutateRecommendation(props.documentUri, !currentlyRecommended); 116 + setOptimisticRecommended(null); 117 + setIsPending(false); 118 + }; 119 + 120 + if (props.expanded) 121 + return ( 122 + <ButtonSecondary 123 + onClick={(e) => { 124 + e.preventDefault(); 125 + e.stopPropagation(); 126 + handleClick(e); 127 + }} 128 + > 129 + {displayRecommended ? ( 130 + <RecommendTinyFilled className="text-accent-contrast" /> 131 + ) : ( 132 + <RecommendTinyEmpty /> 133 + )} 134 + <div className="flex gap-2 items-center"> 135 + {count > 0 && ( 136 + <> 137 + <span 138 + className={`${displayRecommended && "text-accent-contrast"}`} 139 + > 140 + {count} 141 + </span> 142 + <Separator classname="h-4! text-accent-contrast!" /> 143 + </> 144 + )} 145 + {displayRecommended ? "Recommended!" : "Recommend"} 146 + </div> 147 + </ButtonSecondary> 148 + ); 149 + 150 + return ( 151 + <button 152 + onClick={(e) => { 153 + e.preventDefault(); 154 + e.stopPropagation(); 155 + handleClick(e); 156 + }} 157 + disabled={isPending || isLoading} 158 + className={`recommendButton relative flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 159 + aria-label={displayRecommended ? "Remove recommend" : "Recommend"} 160 + > 161 + {displayRecommended ? ( 162 + <RecommendTinyFilled className="text-accent-contrast" /> 163 + ) : ( 164 + <RecommendTinyEmpty /> 165 + )} 166 + {count > 0 && ( 167 + <span className={`${displayRecommended && "text-accent-contrast"}`}> 168 + {count} 169 + </span> 170 + )} 171 + </button> 172 + ); 173 + }
+1
contexts/DocumentContext.tsx
··· 21 | "comments" 22 | "mentions" 23 | "leafletId" 24 >; 25 26 const DocumentContext = createContext<DocumentContextValue | null>(null);
··· 21 | "comments" 22 | "mentions" 23 | "leafletId" 24 + | "recommendsCount" 25 >; 26 27 const DocumentContext = createContext<DocumentContextValue | null>(null);
+44 -39
lexicons/api/types/pub/leaflet/publication.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 { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - import type * as PubLeafletThemeColor from './theme/color' 9 - import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage' 10 11 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'pub.leaflet.publication' 14 15 export interface Record { 16 - $type: 'pub.leaflet.publication' 17 - name: string 18 - base_path?: string 19 - description?: string 20 - icon?: BlobRef 21 - theme?: Theme 22 - preferences?: Preferences 23 - [k: string]: unknown 24 } 25 26 - const hashRecord = 'main' 27 28 export function isRecord<V>(v: V) { 29 - return is$typed(v, id, hashRecord) 30 } 31 32 export function validateRecord<V>(v: V) { 33 - return validate<Record & V>(v, id, hashRecord, true) 34 } 35 36 export interface Preferences { 37 - $type?: 'pub.leaflet.publication#preferences' 38 - showInDiscover: boolean 39 - showComments: boolean 40 - showMentions: boolean 41 - showPrevNext: boolean 42 } 43 44 - const hashPreferences = 'preferences' 45 46 export function isPreferences<V>(v: V) { 47 - return is$typed(v, id, hashPreferences) 48 } 49 50 export function validatePreferences<V>(v: V) { 51 - return validate<Preferences & V>(v, id, hashPreferences) 52 } 53 54 export interface Theme { 55 - $type?: 'pub.leaflet.publication#theme' 56 backgroundColor?: 57 | $Typed<PubLeafletThemeColor.Rgba> 58 | $Typed<PubLeafletThemeColor.Rgb> 59 - | { $type: string } 60 - backgroundImage?: PubLeafletThemeBackgroundImage.Main 61 - pageWidth?: number 62 primary?: 63 | $Typed<PubLeafletThemeColor.Rgba> 64 | $Typed<PubLeafletThemeColor.Rgb> 65 - | { $type: string } 66 pageBackground?: 67 | $Typed<PubLeafletThemeColor.Rgba> 68 | $Typed<PubLeafletThemeColor.Rgb> 69 - | { $type: string } 70 - showPageBackground: boolean 71 accentBackground?: 72 | $Typed<PubLeafletThemeColor.Rgba> 73 | $Typed<PubLeafletThemeColor.Rgb> 74 - | { $type: string } 75 accentText?: 76 | $Typed<PubLeafletThemeColor.Rgba> 77 | $Typed<PubLeafletThemeColor.Rgb> 78 - | { $type: string } 79 } 80 81 - const hashTheme = 'theme' 82 83 export function isTheme<V>(v: V) { 84 - return is$typed(v, id, hashTheme) 85 } 86 87 export function validateTheme<V>(v: V) { 88 - return validate<Theme & V>(v, id, hashTheme) 89 }
··· 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 + import type * as PubLeafletThemeColor from "./theme/color"; 13 + import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage"; 14 15 const is$typed = _is$typed, 16 + validate = _validate; 17 + const id = "pub.leaflet.publication"; 18 19 export interface Record { 20 + $type: "pub.leaflet.publication"; 21 + name: string; 22 + base_path?: string; 23 + description?: string; 24 + icon?: BlobRef; 25 + theme?: Theme; 26 + preferences?: Preferences; 27 + [k: string]: unknown; 28 } 29 30 + const hashRecord = "main"; 31 32 export function isRecord<V>(v: V) { 33 + return is$typed(v, id, hashRecord); 34 } 35 36 export function validateRecord<V>(v: V) { 37 + return validate<Record & V>(v, id, hashRecord, true); 38 } 39 40 export interface Preferences { 41 + $type?: "pub.leaflet.publication#preferences"; 42 + showInDiscover: boolean; 43 + showComments: boolean; 44 + showMentions: boolean; 45 + showPrevNext: boolean; 46 + showRecommends: boolean; 47 } 48 49 + const hashPreferences = "preferences"; 50 51 export function isPreferences<V>(v: V) { 52 + return is$typed(v, id, hashPreferences); 53 } 54 55 export function validatePreferences<V>(v: V) { 56 + return validate<Preferences & V>(v, id, hashPreferences); 57 } 58 59 export interface Theme { 60 + $type?: "pub.leaflet.publication#theme"; 61 backgroundColor?: 62 | $Typed<PubLeafletThemeColor.Rgba> 63 | $Typed<PubLeafletThemeColor.Rgb> 64 + | { $type: string }; 65 + backgroundImage?: PubLeafletThemeBackgroundImage.Main; 66 + pageWidth?: number; 67 primary?: 68 | $Typed<PubLeafletThemeColor.Rgba> 69 | $Typed<PubLeafletThemeColor.Rgb> 70 + | { $type: string }; 71 pageBackground?: 72 | $Typed<PubLeafletThemeColor.Rgba> 73 | $Typed<PubLeafletThemeColor.Rgb> 74 + | { $type: string }; 75 + showPageBackground: boolean; 76 accentBackground?: 77 | $Typed<PubLeafletThemeColor.Rgba> 78 | $Typed<PubLeafletThemeColor.Rgb> 79 + | { $type: string }; 80 accentText?: 81 | $Typed<PubLeafletThemeColor.Rgba> 82 | $Typed<PubLeafletThemeColor.Rgb> 83 + | { $type: string }; 84 } 85 86 + const hashTheme = "theme"; 87 88 export function isTheme<V>(v: V) { 89 + return is$typed(v, id, hashTheme); 90 } 91 92 export function validateTheme<V>(v: V) { 93 + return validate<Theme & V>(v, id, hashTheme); 94 }
+33 -28
lexicons/api/types/site/standard/publication.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 { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - import type * as SiteStandardThemeBasic from './theme/basic' 9 - import type * as PubLeafletPublication from '../../pub/leaflet/publication' 10 11 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'site.standard.publication' 14 15 export interface Record { 16 - $type: 'site.standard.publication' 17 - basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 19 - description?: string 20 - icon?: BlobRef 21 - name: string 22 - preferences?: Preferences 23 - url: string 24 - [k: string]: unknown 25 } 26 27 - const hashRecord = 'main' 28 29 export function isRecord<V>(v: V) { 30 - return is$typed(v, id, hashRecord) 31 } 32 33 export function validateRecord<V>(v: V) { 34 - return validate<Record & V>(v, id, hashRecord, true) 35 } 36 37 export interface Preferences { 38 - $type?: 'site.standard.publication#preferences' 39 - showInDiscover: boolean 40 - showComments: boolean 41 - showMentions: boolean 42 - showPrevNext: boolean 43 } 44 45 - const hashPreferences = 'preferences' 46 47 export function isPreferences<V>(v: V) { 48 - return is$typed(v, id, hashPreferences) 49 } 50 51 export function validatePreferences<V>(v: V) { 52 - return validate<Preferences & V>(v, id, hashPreferences) 53 }
··· 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 + import type * as SiteStandardThemeBasic from "./theme/basic"; 13 + import type * as PubLeafletPublication from "../../pub/leaflet/publication"; 14 15 const is$typed = _is$typed, 16 + validate = _validate; 17 + const id = "site.standard.publication"; 18 19 export interface Record { 20 + $type: "site.standard.publication"; 21 + basicTheme?: SiteStandardThemeBasic.Main; 22 + theme?: $Typed<PubLeafletPublication.Theme> | { $type: string }; 23 + description?: string; 24 + icon?: BlobRef; 25 + name: string; 26 + preferences?: Preferences; 27 + url: string; 28 + [k: string]: unknown; 29 } 30 31 + const hashRecord = "main"; 32 33 export function isRecord<V>(v: V) { 34 + return is$typed(v, id, hashRecord); 35 } 36 37 export function validateRecord<V>(v: V) { 38 + return validate<Record & V>(v, id, hashRecord, true); 39 } 40 41 export interface Preferences { 42 + $type?: "site.standard.publication#preferences"; 43 + showInDiscover: boolean; 44 + showComments: boolean; 45 + showMentions: boolean; 46 + showPrevNext: boolean; 47 + showRecommends: boolean; 48 } 49 50 + const hashPreferences = "preferences"; 51 52 export function isPreferences<V>(v: V) { 53 + return is$typed(v, id, hashPreferences); 54 } 55 56 export function validatePreferences<V>(v: V) { 57 + return validate<Preferences & V>(v, id, hashPreferences); 58 }
+4
lexicons/pub/leaflet/publication.json
··· 59 "showPrevNext": { 60 "type": "boolean", 61 "default": true 62 } 63 } 64 },
··· 59 "showPrevNext": { 60 "type": "boolean", 61 "default": true 62 + }, 63 + "showRecommends": { 64 + "type": "boolean", 65 + "default": true 66 } 67 } 68 },
+4
lexicons/site/standard/publication.json
··· 58 "showPrevNext": { 59 "default": false, 60 "type": "boolean" 61 } 62 }, 63 "type": "object"
··· 58 "showPrevNext": { 59 "default": false, 60 "type": "boolean" 61 + }, 62 + "showRecommends": { 63 + "default": true, 64 + "type": "boolean" 65 } 66 }, 67 "type": "object"
+16 -11
lexicons/src/normalize.ts
··· 50 * Checks if the record is a pub.leaflet.document 51 */ 52 export function isLeafletDocument( 53 - record: unknown 54 ): record is PubLeafletDocument.Record { 55 if (!record || typeof record !== "object") return false; 56 const r = record as Record<string, unknown>; ··· 65 * Checks if the record is a site.standard.document 66 */ 67 export function isStandardDocument( 68 - record: unknown 69 ): record is SiteStandardDocument.Record { 70 if (!record || typeof record !== "object") return false; 71 const r = record as Record<string, unknown>; ··· 76 * Checks if the record is a pub.leaflet.publication 77 */ 78 export function isLeafletPublication( 79 - record: unknown 80 ): record is PubLeafletPublication.Record { 81 if (!record || typeof record !== "object") return false; 82 const r = record as Record<string, unknown>; ··· 91 * Checks if the record is a site.standard.publication 92 */ 93 export function isStandardPublication( 94 - record: unknown 95 ): record is SiteStandardPublication.Record { 96 if (!record || typeof record !== "object") return false; 97 const r = record as Record<string, unknown>; ··· 106 | $Typed<PubLeafletThemeColor.Rgba> 107 | $Typed<PubLeafletThemeColor.Rgb> 108 | { $type: string } 109 - | undefined 110 ): { r: number; g: number; b: number } | undefined { 111 if (!color || typeof color !== "object") return undefined; 112 const c = color as Record<string, unknown>; ··· 124 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 */ 126 export function leafletThemeToBasicTheme( 127 - theme: PubLeafletPublication.Theme | undefined 128 ): SiteStandardThemeBasic.Main | undefined { 129 if (!theme) return undefined; 130 131 const background = extractRgb(theme.backgroundColor); 132 - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 const accentForeground = extractRgb(theme.accentText); 134 135 // If we don't have the required colors, return undefined ··· 160 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 */ 163 - export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 164 if (!record || typeof record !== "object") return null; 165 166 // Pass through site.standard records directly (theme is already in correct format if present) ··· 219 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 */ 221 export function normalizePublication( 222 - record: unknown 223 ): NormalizedPublication | null { 224 if (!record || typeof record !== "object") return null; 225 ··· 268 showComments: record.preferences.showComments, 269 showMentions: record.preferences.showMentions, 270 showPrevNext: record.preferences.showPrevNext, 271 } 272 : undefined; 273 ··· 290 * Type guard to check if a normalized document has leaflet content 291 */ 292 export function hasLeafletContent( 293 - doc: NormalizedDocument 294 ): doc is NormalizedDocument & { 295 content: $Typed<PubLeafletContent.Main>; 296 } { ··· 304 * Gets the pages array from a normalized document, handling both formats 305 */ 306 export function getDocumentPages( 307 - doc: NormalizedDocument 308 ): PubLeafletContent.Main["pages"] | undefined { 309 if (!doc.content) return undefined; 310
··· 50 * Checks if the record is a pub.leaflet.document 51 */ 52 export function isLeafletDocument( 53 + record: unknown, 54 ): record is PubLeafletDocument.Record { 55 if (!record || typeof record !== "object") return false; 56 const r = record as Record<string, unknown>; ··· 65 * Checks if the record is a site.standard.document 66 */ 67 export function isStandardDocument( 68 + record: unknown, 69 ): record is SiteStandardDocument.Record { 70 if (!record || typeof record !== "object") return false; 71 const r = record as Record<string, unknown>; ··· 76 * Checks if the record is a pub.leaflet.publication 77 */ 78 export function isLeafletPublication( 79 + record: unknown, 80 ): record is PubLeafletPublication.Record { 81 if (!record || typeof record !== "object") return false; 82 const r = record as Record<string, unknown>; ··· 91 * Checks if the record is a site.standard.publication 92 */ 93 export function isStandardPublication( 94 + record: unknown, 95 ): record is SiteStandardPublication.Record { 96 if (!record || typeof record !== "object") return false; 97 const r = record as Record<string, unknown>; ··· 106 | $Typed<PubLeafletThemeColor.Rgba> 107 | $Typed<PubLeafletThemeColor.Rgb> 108 | { $type: string } 109 + | undefined, 110 ): { r: number; g: number; b: number } | undefined { 111 if (!color || typeof color !== "object") return undefined; 112 const c = color as Record<string, unknown>; ··· 124 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 */ 126 export function leafletThemeToBasicTheme( 127 + theme: PubLeafletPublication.Theme | undefined, 128 ): SiteStandardThemeBasic.Main | undefined { 129 if (!theme) return undefined; 130 131 const background = extractRgb(theme.backgroundColor); 132 + const accent = 133 + extractRgb(theme.accentBackground) || extractRgb(theme.primary); 134 const accentForeground = extractRgb(theme.accentText); 135 136 // If we don't have the required colors, return undefined ··· 161 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 162 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 163 */ 164 + export function normalizeDocument( 165 + record: unknown, 166 + uri?: string, 167 + ): NormalizedDocument | null { 168 if (!record || typeof record !== "object") return null; 169 170 // Pass through site.standard records directly (theme is already in correct format if present) ··· 223 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 224 */ 225 export function normalizePublication( 226 + record: unknown, 227 ): NormalizedPublication | null { 228 if (!record || typeof record !== "object") return null; 229 ··· 272 showComments: record.preferences.showComments, 273 showMentions: record.preferences.showMentions, 274 showPrevNext: record.preferences.showPrevNext, 275 + showRecommends: record.preferences.showRecommends, 276 } 277 : undefined; 278 ··· 295 * Type guard to check if a normalized document has leaflet content 296 */ 297 export function hasLeafletContent( 298 + doc: NormalizedDocument, 299 ): doc is NormalizedDocument & { 300 content: $Typed<PubLeafletContent.Main>; 301 } { ··· 309 * Gets the pages array from a normalized document, handling both formats 310 */ 311 export function getDocumentPages( 312 + doc: NormalizedDocument, 313 ): PubLeafletContent.Main["pages"] | undefined { 314 if (!doc.content) return undefined; 315
+1
lexicons/src/publication.ts
··· 29 showComments: { type: "boolean", default: true }, 30 showMentions: { type: "boolean", default: true }, 31 showPrevNext: { type: "boolean", default: true }, 32 }, 33 }, 34 theme: {
··· 29 showComments: { type: "boolean", default: true }, 30 showMentions: { type: "boolean", default: true }, 31 showPrevNext: { type: "boolean", default: true }, 32 + showRecommends: { type: "boolean", default: true }, 33 }, 34 }, 35 theme: {
+16
package-lock.json
··· 38 "@vercel/analytics": "^1.5.0", 39 "@vercel/functions": "^2.2.12", 40 "@vercel/sdk": "^1.11.4", 41 "babel-plugin-react-compiler": "^19.1.0-rc.1", 42 "base64-js": "^1.5.1", 43 "colorjs.io": "^0.5.2", ··· 8660 "optional": true 8661 } 8662 } 8663 }, 8664 "node_modules/abort-controller": { 8665 "version": "3.0.0",
··· 38 "@vercel/analytics": "^1.5.0", 39 "@vercel/functions": "^2.2.12", 40 "@vercel/sdk": "^1.11.4", 41 + "@yornaath/batshit": "^0.14.0", 42 "babel-plugin-react-compiler": "^19.1.0-rc.1", 43 "base64-js": "^1.5.1", 44 "colorjs.io": "^0.5.2", ··· 8661 "optional": true 8662 } 8663 } 8664 + }, 8665 + "node_modules/@yornaath/batshit": { 8666 + "version": "0.14.0", 8667 + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", 8668 + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", 8669 + "license": "MIT", 8670 + "dependencies": { 8671 + "@yornaath/batshit-devtools": "^1.7.1" 8672 + } 8673 + }, 8674 + "node_modules/@yornaath/batshit-devtools": { 8675 + "version": "1.7.1", 8676 + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", 8677 + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", 8678 + "license": "MIT" 8679 }, 8680 "node_modules/abort-controller": { 8681 "version": "3.0.0",
+1
package.json
··· 49 "@vercel/analytics": "^1.5.0", 50 "@vercel/functions": "^2.2.12", 51 "@vercel/sdk": "^1.11.4", 52 "babel-plugin-react-compiler": "^19.1.0-rc.1", 53 "base64-js": "^1.5.1", 54 "colorjs.io": "^0.5.2",
··· 49 "@vercel/analytics": "^1.5.0", 50 "@vercel/functions": "^2.2.12", 51 "@vercel/sdk": "^1.11.4", 52 + "@yornaath/batshit": "^0.14.0", 53 "babel-plugin-react-compiler": "^19.1.0-rc.1", 54 "base64-js": "^1.5.1", 55 "colorjs.io": "^0.5.2",