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 26 `*, 27 27 comments_on_documents(count), 28 28 document_mentions_in_bsky(count), 29 + recommends_on_documents(count), 29 30 documents_in_publications(publications(*))`, 30 31 ) 31 32 .like("uri", `at://${did}/%`) ··· 39 40 ); 40 41 } 41 42 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 - ]); 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 + ]); 54 56 55 57 // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 56 58 const docs = deduplicateByUriOrdered(rawDocs || []); ··· 82 84 sort_date: doc.sort_date, 83 85 comments_on_documents: doc.comments_on_documents, 84 86 document_mentions_in_bsky: doc.document_mentions_in_bsky, 87 + recommends_on_documents: doc.recommends_on_documents, 85 88 }, 86 89 }; 87 90
+3
app/(home-pages)/reader/getReaderFeed.ts
··· 32 32 `*, 33 33 comments_on_documents(count), 34 34 document_mentions_in_bsky(count), 35 + recommends_on_documents(count), 35 36 documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 36 37 ) 37 38 .eq( ··· 76 77 documents: { 77 78 comments_on_documents: post.comments_on_documents, 78 79 document_mentions_in_bsky: post.document_mentions_in_bsky, 80 + recommends_on_documents: post.recommends_on_documents, 79 81 data: normalizedData, 80 82 uri: post.uri, 81 83 sort_date: post.sort_date, ··· 112 114 sort_date: string; 113 115 comments_on_documents: { count: number }[] | undefined; 114 116 document_mentions_in_bsky: { count: number }[] | undefined; 117 + recommends_on_documents: { count: number }[] | undefined; 115 118 }; 116 119 };
+2
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 21 21 `*, 22 22 comments_on_documents(count), 23 23 document_mentions_in_bsky(count), 24 + recommends_on_documents(count), 24 25 documents_in_publications(publications(*))`, 25 26 ) 26 27 .contains("data->tags", `["${tag}"]`) ··· 67 68 documents: { 68 69 comments_on_documents: doc.comments_on_documents, 69 70 document_mentions_in_bsky: doc.document_mentions_in_bsky, 71 + recommends_on_documents: doc.recommends_on_documents, 70 72 data: normalizedData, 71 73 uri: doc.uri, 72 74 sort_date: doc.sort_date,
+2 -1
app/api/oauth/[route]/route.ts
··· 105 105 }) 106 106 .select() 107 107 .single(); 108 - 108 + console.log({ token }); 109 109 if (token) await setAuthToken(token.id); 110 110 111 111 // Process successful authentication here ··· 114 114 console.log("User authenticated as:", session.did); 115 115 return handleAction(s.action, redirectPath); 116 116 } catch (e) { 117 + console.log(e); 117 118 redirect(redirectPath); 118 119 } 119 120 }
+4 -1
app/api/rpc/[command]/get_publication_data.ts
··· 40 40 documents_in_publications(documents( 41 41 *, 42 42 comments_on_documents(count), 43 - document_mentions_in_bsky(count) 43 + document_mentions_in_bsky(count), 44 + recommends_on_documents(count) 44 45 )), 45 46 publication_subscriptions(*, identities(bsky_profiles(*))), 46 47 publication_domains(*), ··· 87 88 data: dip.documents.data, 88 89 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 89 90 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 91 + recommendsCount: 92 + dip.documents.recommends_on_documents?.[0]?.count || 0, 90 93 }; 91 94 }) 92 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 14 import { search_publication_names } from "./search_publication_names"; 15 15 import { search_publication_documents } from "./search_publication_documents"; 16 16 import { get_profile_data } from "./get_profile_data"; 17 + import { get_user_recommendations } from "./get_user_recommendations"; 17 18 18 19 let supabase = createClient<Database>( 19 20 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 41 42 search_publication_names, 42 43 search_publication_documents, 43 44 get_profile_data, 45 + get_user_recommendations, 44 46 ]; 45 47 export async function POST( 46 48 req: Request,
+3 -1
app/lish/Subscribe.tsx
··· 87 87 return ( 88 88 <Popover 89 89 trigger={ 90 - <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + <div className="text-accent-contrast text-sm w-fit"> 91 + Manage Subscription 92 + </div> 91 93 } 92 94 > 93 95 <div className="max-w-sm flex flex-col gap-1">
+6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 71 71 preferences={preferences} 72 72 commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 73 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 + recommendsCount={document.recommendsCount} 74 75 /> 75 76 <CanvasContent 76 77 blocks={blocks} ··· 205 206 preferences: { 206 207 showComments?: boolean; 207 208 showMentions?: boolean; 209 + showRecommends?: boolean; 208 210 showPrevNext?: boolean; 209 211 }; 210 212 quotesCount: number | undefined; 211 213 commentsCount: number | undefined; 214 + recommendsCount: number; 212 215 }) => { 213 216 let isMobile = useIsMobile(); 214 217 return ( ··· 216 219 <Interactions 217 220 quotesCount={props.quotesCount || 0} 218 221 commentsCount={props.commentsCount || 0} 222 + recommendsCount={props.recommendsCount} 219 223 showComments={props.preferences.showComments !== false} 220 224 showMentions={props.preferences.showMentions !== false} 225 + showRecommends={props.preferences.showRecommends !== false} 221 226 pageId={props.pageId} 222 227 /> 223 228 {!props.isSubpage && ( ··· 233 238 data={props.data} 234 239 profile={props.profile} 235 240 preferences={props.preferences} 241 + isCanvas 236 242 /> 237 243 </Popover> 238 244 </>
+83 -41
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 18 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 19 19 import { EditTiny } from "components/Icons/EditTiny"; 20 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"; 21 24 22 25 export type InteractionState = { 23 26 drawerOpen: undefined | boolean; ··· 105 108 export const Interactions = (props: { 106 109 quotesCount: number; 107 110 commentsCount: number; 111 + recommendsCount: number; 108 112 className?: string; 109 113 showComments: boolean; 110 114 showMentions: boolean; 115 + showRecommends: boolean; 111 116 pageId?: string; 112 117 }) => { 113 - const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 118 + const { 119 + uri: document_uri, 120 + quotesAndMentions, 121 + normalizedDocument, 122 + } = useDocument(); 114 123 let { identity } = useIdentityData(); 115 124 116 125 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 124 133 const tags = normalizedDocument.tags; 125 134 const tagCount = tags?.length || 0; 126 135 136 + let interactionsAvailable = 137 + props.showComments || props.showMentions || props.showRecommends; 138 + 127 139 return ( 128 - <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 129 - {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 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 + )} 130 149 150 + {/*MENTIONS BUTTON*/} 131 151 {props.quotesCount === 0 || props.showMentions === false ? null : ( 132 152 <button 133 - className="flex w-fit gap-2 items-center" 153 + className="flex w-fit gap-1 items-center" 134 154 onClick={() => { 135 155 if (!drawerOpen || drawer !== "quotes") 136 156 openInteractionDrawer("quotes", document_uri, props.pageId); ··· 143 163 <QuoteTiny aria-hidden /> {props.quotesCount} 144 164 </button> 145 165 )} 166 + {/*COMMENT BUTTON*/} 146 167 {props.showComments === false ? null : ( 147 168 <button 148 - className="flex gap-2 items-center w-fit" 169 + className="flex gap-1 items-center w-fit" 149 170 onClick={() => { 150 171 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 151 172 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 177 <CommentTiny aria-hidden /> {props.commentsCount} 157 178 </button> 158 179 )} 180 + 181 + {tagCount > 0 && ( 182 + <> 183 + {interactionsAvailable && <Separator classname="h-4!" />} 184 + <TagPopover tags={tags} tagCount={tagCount} /> 185 + </> 186 + )} 159 187 </div> 160 188 ); 161 189 }; ··· 163 191 export const ExpandedInteractions = (props: { 164 192 quotesCount: number; 165 193 commentsCount: number; 194 + recommendsCount: number; 166 195 className?: string; 167 196 showComments: boolean; 168 197 showMentions: boolean; 198 + showRecommends: boolean; 169 199 pageId?: string; 170 200 }) => { 171 - const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 201 + const { 202 + uri: document_uri, 203 + quotesAndMentions, 204 + normalizedDocument, 205 + publication, 206 + leafletId, 207 + } = useDocument(); 172 208 let { identity } = useIdentityData(); 173 209 174 210 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); ··· 182 218 const tags = normalizedDocument.tags; 183 219 const tagCount = tags?.length || 0; 184 220 185 - let noInteractions = !props.showComments && !props.showMentions; 221 + let noInteractions = 222 + !props.showComments && !props.showMentions && !props.showRecommends; 186 223 187 224 let subscribed = 188 225 identity?.atp_did && ··· 191 228 (s) => s.identity === identity.atp_did, 192 229 ); 193 230 194 - let isAuthor = 195 - identity && 196 - identity.atp_did === publication?.identity_did && 197 - leafletId; 198 - 199 231 return ( 200 232 <div 201 233 className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} ··· 214 246 {noInteractions ? ( 215 247 <div /> 216 248 ) : ( 217 - <> 218 - <div className="flex gap-2"> 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 + )} 219 258 {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" 259 + <ButtonSecondary 222 260 onClick={() => { 223 261 if (!drawerOpen || drawer !== "quotes") 224 262 openInteractionDrawer( ··· 233 271 onTouchStart={handleQuotePrefetch} 234 272 aria-label="Post quotes" 235 273 > 236 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 237 - <span 238 - aria-hidden 239 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 240 - </button> 274 + <QuoteTiny aria-hidden /> {props.quotesCount} 275 + <Separator classname="h-4! text-accent-contrast!" /> 276 + Mention{props.quotesCount > 1 ? "s" : ""} 277 + </ButtonSecondary> 241 278 )} 242 279 {!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" 280 + <ButtonSecondary 245 281 onClick={() => { 246 282 if ( 247 283 !drawerOpen || ··· 259 295 aria-label="Post comments" 260 296 > 261 297 <CommentTiny aria-hidden />{" "} 262 - {props.commentsCount > 0 ? ( 263 - <span aria-hidden> 264 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 265 - </span> 266 - ) : ( 267 - "Comment" 298 + {props.commentsCount > 0 && ( 299 + <> 300 + {props.commentsCount} 301 + <Separator classname="h-4! text-accent-contrast!" /> 302 + </> 268 303 )} 269 - </button> 304 + Comment{props.commentsCount > 1 ? "s" : ""} 305 + </ButtonSecondary> 270 306 )} 271 307 </div> 272 - </> 308 + {subscribed && publication && ( 309 + <ManageSubscription 310 + base_url={getPublicationURL(publication)} 311 + pub_uri={publication.uri} 312 + subscribers={publication.publication_subscriptions} 313 + /> 314 + )} 315 + </div> 273 316 )} 274 317 275 318 <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 319 </div> 284 320 </div> 285 321 ); ··· 313 349 </div> 314 350 ); 315 351 }; 316 - export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 352 + export function getQuoteCount( 353 + quotesAndMentions: { uri: string; link?: string }[], 354 + pageId?: string, 355 + ) { 317 356 return getQuoteCountFromArray(quotesAndMentions, pageId); 318 357 } 319 358 ··· 338 377 } 339 378 } 340 379 341 - export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 380 + export function getCommentCount( 381 + comments: CommentOnDocument[], 382 + pageId?: string, 383 + ) { 342 384 if (pageId) 343 385 return comments.filter( 344 386 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ··· 362 404 return ( 363 405 <a 364 406 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" 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" 366 408 > 367 409 <EditTiny /> Edit Post 368 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 87 pageId={pageId} 88 88 showComments={preferences.showComments !== false} 89 89 showMentions={preferences.showMentions !== false} 90 - commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 90 + showRecommends={preferences.showRecommends !== false} 91 + commentsCount={ 92 + getCommentCount(document.comments_on_documents, pageId) || 0 93 + } 91 94 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 95 + recommendsCount={document.recommendsCount} 92 96 /> 93 97 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 94 98 </PageWrapper>
+20 -7
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 18 export function PostHeader(props: { 19 19 data: PostPageData; 20 20 profile: ProfileViewDetailed; 21 - preferences: { showComments?: boolean; showMentions?: boolean }; 21 + preferences: { 22 + showComments?: boolean; 23 + showMentions?: boolean; 24 + showRecommends?: boolean; 25 + }; 26 + isCanvas?: boolean; 22 27 }) { 23 28 let { identity } = useIdentityData(); 24 29 let document = props.data; ··· 84 89 </> 85 90 ) : null} 86 91 </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 - /> 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 + )} 93 106 </> 94 107 } 95 108 />
+2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 170 170 preferences: { 171 171 showComments?: boolean; 172 172 showMentions?: boolean; 173 + showRecommends?: boolean; 173 174 showPrevNext?: boolean; 174 175 }; 175 176 pubRecord?: NormalizedPublication | null; ··· 233 234 preferences: { 234 235 showComments?: boolean; 235 236 showMentions?: boolean; 237 + showRecommends?: boolean; 236 238 showPrevNext?: boolean; 237 239 }; 238 240 pollData: PollData[];
+31 -12
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 22 22 publication_subscriptions(*)) 23 23 ), 24 24 document_mentions_in_bsky(*), 25 - leaflets_in_publications(*) 25 + leaflets_in_publications(*), 26 + recommends_on_documents(count) 26 27 `, 27 28 ) 28 29 .or(documentUriFilter(did, rkey)) ··· 33 34 if (!document) return null; 34 35 35 36 // Normalize the document record - this is the primary way consumers should access document data 36 - const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 37 + const normalizedDocument = normalizeDocumentRecord( 38 + document.data, 39 + document.uri, 40 + ); 37 41 if (!normalizedDocument) return null; 38 42 39 43 // Normalize the publication record - this is the primary way consumers should access publication data 40 44 const normalizedPublication = normalizePublicationRecord( 41 - document.documents_in_publications[0]?.publications?.record 45 + document.documents_in_publications[0]?.publications?.record, 42 46 ); 43 47 44 48 // Fetch constellation backlinks for mentions ··· 83 87 // Filter and sort documents by publishedAt 84 88 const sortedDocs = allDocs 85 89 .map((dip) => { 86 - const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 90 + const normalizedData = normalizeDocumentRecord( 91 + dip?.documents?.data, 92 + dip?.documents?.uri, 93 + ); 87 94 return { 88 95 uri: dip?.documents?.uri, 89 96 title: normalizedData?.title, ··· 98 105 ); 99 106 100 107 // Find current document index 101 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 108 + const currentIndex = sortedDocs.findIndex( 109 + (doc) => doc.uri === document.uri, 110 + ); 102 111 103 112 if (currentIndex !== -1) { 104 113 prevNext = { ··· 122 131 123 132 // Build explicit publication context for consumers 124 133 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; 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; 132 149 133 150 return { 134 151 ...document, ··· 143 160 comments: document.comments_on_documents, 144 161 mentions: document.document_mentions_in_bsky, 145 162 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 163 + // Recommends data 164 + recommendsCount, 146 165 }; 147 166 } 148 167
+8 -8
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 60 60 61 61 function PublishedPostItem(props: { 62 62 doc: PublishedDocument; 63 - publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 63 + publication: NonNullable< 64 + NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"] 65 + >; 64 66 pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 67 showPageBackground: boolean; 66 68 }) { ··· 94 96 <div className="flex justify-start align-top flex-row gap-1"> 95 97 {leaflet && leaflet.permission_tokens && ( 96 98 <> 97 - <SpeedyLink 98 - className="pt-[6px]" 99 - href={`/${leaflet.leaflet}`} 100 - > 99 + <SpeedyLink className="pt-[6px]" href={`/${leaflet.leaflet}`}> 101 100 <EditTiny /> 102 101 </SpeedyLink> 103 102 ··· 129 128 </div> 130 129 131 130 {doc.record.description ? ( 132 - <p className="italic text-secondary"> 133 - {doc.record.description} 134 - </p> 131 + <p className="italic text-secondary">{doc.record.description}</p> 135 132 ) : null} 136 133 <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 137 134 {doc.record.publishedAt ? ( ··· 140 137 <InteractionPreview 141 138 quotesCount={doc.mentionsCount} 142 139 commentsCount={doc.commentsCount} 140 + recommendsCount={doc.recommendsCount} 141 + documentUri={doc.uri} 143 142 tags={doc.record.tags || []} 144 143 showComments={pubRecord?.preferences?.showComments !== false} 145 144 showMentions={pubRecord?.preferences?.showMentions !== false} 145 + showRecommends={pubRecord?.preferences?.showRecommends !== false} 146 146 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 147 147 /> 148 148 </div>
+21 -1
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 29 29 ? true 30 30 : record.preferences.showMentions, 31 31 ); 32 + let [showRecommends, setShowRecommends] = useState( 33 + record?.preferences?.showRecommends === undefined 34 + ? true 35 + : record.preferences.showRecommends, 36 + ); 32 37 let [showPrevNext, setShowPrevNext] = useState( 33 38 record?.preferences?.showPrevNext === undefined 34 39 ? true ··· 53 58 showComments: showComments, 54 59 showMentions: showMentions, 55 60 showPrevNext: showPrevNext, 61 + showRecommends: showRecommends, 56 62 }, 57 63 }); 58 64 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 99 105 <div className="flex flex-col justify-start"> 100 106 <div className="font-bold">Show Mentions</div> 101 107 <div className="text-tertiary text-sm leading-tight"> 102 - Display a list of posts on Bluesky that mention your post 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 103 123 </div> 104 124 </div> 105 125 </Toggle>
+14 -8
app/lish/[did]/[publication]/page.tsx
··· 38 38 documents_in_publications(documents( 39 39 *, 40 40 comments_on_documents(count), 41 - document_mentions_in_bsky(count) 41 + document_mentions_in_bsky(count), 42 + recommends_on_documents(count) 42 43 )) 43 44 `, 44 45 ) ··· 119 120 }) 120 121 .map((doc) => { 121 122 if (!doc.documents) return null; 122 - const doc_record = normalizeDocumentRecord(doc.documents.data); 123 + const doc_record = normalizeDocumentRecord( 124 + doc.documents.data, 125 + ); 123 126 if (!doc_record) return null; 124 127 let uri = new AtUri(doc.documents.uri); 125 128 let quotes = ··· 128 131 record?.preferences?.showComments === false 129 132 ? 0 130 133 : doc.documents.comments_on_documents[0].count || 0; 134 + let recommends = 135 + doc.documents.recommends_on_documents?.[0]?.count || 0; 131 136 let tags = doc_record.tags || []; 132 137 133 138 return ( ··· 143 148 </p> 144 149 </SpeedyLink> 145 150 146 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 151 + <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 147 152 <p className="text-sm text-tertiary "> 148 153 {doc_record.publishedAt && ( 149 154 <LocalizedDate ··· 156 161 /> 157 162 )}{" "} 158 163 </p> 159 - {comments > 0 || quotes > 0 || tags.length > 0 ? ( 160 - <Separator classname="h-4! mx-1" /> 161 - ) : ( 162 - "" 163 - )} 164 + 164 165 <InteractionPreview 165 166 quotesCount={quotes} 166 167 commentsCount={comments} 168 + recommendsCount={recommends} 169 + documentUri={doc.documents.uri} 167 170 tags={tags} 168 171 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 172 showComments={ ··· 171 174 } 172 175 showMentions={ 173 176 record?.preferences?.showMentions !== false 177 + } 178 + showRecommends={ 179 + record?.preferences?.showRecommends !== false 174 180 } 175 181 /> 176 182 </div>
+1
app/lish/createPub/CreatePubForm.tsx
··· 58 58 showComments: true, 59 59 showMentions: true, 60 60 showPrevNext: true, 61 + showRecommends: true, 61 62 }, 62 63 }); 63 64
+1 -2
app/lish/createPub/UpdatePubForm.tsx
··· 88 88 showComments: showComments, 89 89 showMentions: showMentions, 90 90 showPrevNext: showPrevNext, 91 + showRecommends: record?.preferences?.showRecommends ?? true, 91 92 }, 92 93 }); 93 94 toast({ type: "success", content: "Updated!" }); ··· 194 195 </p> 195 196 </div> 196 197 </Toggle> 197 - 198 - 199 198 </div> 200 199 </form> 201 200 );
+23 -9
app/lish/createPub/createPublication.ts
··· 5 5 PubLeafletPublication, 6 6 SiteStandardPublication, 7 7 } from "lexicons/api"; 8 - import { 9 - restoreOAuthSession, 10 - OAuthSessionError, 11 - } from "src/atproto-oauth"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 12 9 import { getIdentityData } from "actions/getIdentityData"; 13 10 import { supabaseServerClient } from "supabase/serverClient"; 14 11 import { Json } from "supabase/database.types"; ··· 76 73 77 74 // Build record based on publication type 78 75 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 - let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 76 + let iconBlob: 77 + | Awaited< 78 + ReturnType<typeof agent.com.atproto.repo.uploadBlob> 79 + >["data"]["blob"] 80 + | undefined; 80 81 81 82 // Upload the icon if provided 82 83 if (iconFile && iconFile.size > 0) { ··· 97 98 ...(iconBlob && { icon: iconBlob }), 98 99 basicTheme: { 99 100 $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 }, 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 + }, 104 117 }, 105 118 preferences: { 106 119 showInDiscover: preferences.showInDiscover, 107 120 showComments: preferences.showComments, 108 121 showMentions: preferences.showMentions, 109 122 showPrevNext: preferences.showPrevNext, 123 + showRecommends: preferences.showRecommends, 110 124 }, 111 125 } satisfies SiteStandardPublication.Record; 112 126 } else {
+167 -98
app/lish/createPub/updatePublication.ts
··· 77 77 } 78 78 79 79 const aturi = new AtUri(existingPub.uri); 80 - const publicationType = getPublicationType(aturi.collection) as PublicationType; 80 + const publicationType = getPublicationType( 81 + aturi.collection, 82 + ) as PublicationType; 81 83 82 84 // Normalize existing record 83 85 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 128 130 } 129 131 130 132 /** Merges override with existing value, respecting explicit undefined */ 131 - function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 133 + function resolveField<T>( 134 + override: T | undefined, 135 + existing: T | undefined, 136 + hasOverride: boolean, 137 + ): T | undefined { 132 138 return hasOverride ? override : existing; 133 139 } 134 140 ··· 146 152 return { 147 153 $type: "pub.leaflet.publication", 148 154 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), 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 + ), 152 170 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, 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, 160 181 }; 161 182 } 162 183 ··· 175 196 return { 176 197 $type: "site.standard.publication", 177 198 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), 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 + ), 182 219 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, 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, 189 229 }; 190 230 } 191 231 ··· 217 257 iconFile?: File | null; 218 258 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 259 }): 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; 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 + } 231 274 } 232 - } 233 275 234 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 - name, 236 - description, 237 - icon: iconBlob, 238 - preferences, 239 - }); 240 - }); 276 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 277 + name, 278 + description, 279 + icon: iconBlob, 280 + preferences, 281 + }); 282 + }, 283 + ); 241 284 } 242 285 243 286 export async function updatePublicationBasePath({ ··· 247 290 uri: string; 248 291 base_path: string; 249 292 }): Promise<UpdatePublicationResult> { 250 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 - basePath: base_path, 253 - }); 254 - }); 293 + return withPublicationUpdate( 294 + uri, 295 + async ({ normalizedPub, existingBasePath, publicationType }) => { 296 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 297 + basePath: base_path, 298 + }); 299 + }, 300 + ); 255 301 } 256 302 257 303 type Color = ··· 275 321 accentText: Color; 276 322 }; 277 323 }): 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 - }; 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 + }; 317 365 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 - }; 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 + }; 326 394 327 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 - theme: themeData, 329 - basicTheme, 330 - }); 331 - }); 395 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 396 + theme: themeData, 397 + basicTheme, 398 + }); 399 + }, 400 + ); 332 401 }
+18 -3
components/Canvas.tsx
··· 19 19 import { Separator } from "./Layout"; 20 20 import { CommentTiny } from "./Icons/CommentTiny"; 21 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 - import { PublicationMetadata } from "./Pages/PublicationMetadata"; 22 + import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 25 import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 + import { RecommendTinyEmpty } from "./Icons/RecommendTiny"; 26 27 27 28 export function Canvas(props: { 28 29 entityID: string; ··· 168 169 if (!normalizedPublication) return null; 169 170 let showComments = normalizedPublication.preferences?.showComments !== false; 170 171 let showMentions = normalizedPublication.preferences?.showMentions !== false; 172 + let showRecommends = 173 + normalizedPublication.preferences?.showRecommends !== false; 171 174 172 175 return ( 173 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 + )} 174 182 {showComments && ( 175 183 <div className="flex gap-1 text-tertiary items-center"> 176 184 <CommentTiny className="text-border" /> โ€” 177 185 </div> 178 186 )} 179 - {showComments && ( 187 + {showMentions && ( 180 188 <div className="flex gap-1 text-tertiary items-center"> 181 189 <QuoteTiny className="text-border" /> โ€” 182 190 </div> 183 191 )} 184 192 193 + {showMentions !== false || 194 + showComments !== false || 195 + showRecommends === false ? ( 196 + <Separator classname="h-4!" /> 197 + ) : null} 198 + <AddTags /> 199 + 185 200 {!props.isSubpage && ( 186 201 <> 187 202 <Separator classname="h-5" /> ··· 191 206 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 207 trigger={<InfoSmall />} 193 208 > 194 - <PublicationMetadata /> 209 + <PublicationMetadata noInteractions /> 195 210 </Popover> 196 211 </> 197 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 7 import { Popover } from "./Popover"; 8 8 import { TagTiny } from "./Icons/TagTiny"; 9 9 import { SpeedyLink } from "./SpeedyLink"; 10 + import { RecommendButton } from "./RecommendButton"; 10 11 11 12 export const InteractionPreview = (props: { 12 13 quotesCount: number; 13 14 commentsCount: number; 15 + recommendsCount: number; 16 + documentUri: string; 14 17 tags?: string[]; 15 18 postUrl: string; 16 19 showComments: boolean; 17 20 showMentions: boolean; 21 + showRecommends: boolean; 18 22 19 23 share?: boolean; 20 24 }) => { 21 25 let smoker = useSmoker(); 22 26 let interactionsAvailable = 23 27 (props.quotesCount > 0 && props.showMentions) || 24 - (props.showComments !== false && props.commentsCount > 0); 28 + (props.showComments !== false && props.commentsCount > 0) || 29 + (props.showRecommends !== false && props.recommendsCount > 0); 25 30 26 31 const tagsCount = props.tags?.length || 0; 27 32 28 33 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 - </> 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 + /> 39 40 )} 40 41 41 42 {!props.showMentions || props.quotesCount === 0 ? null : ( ··· 56 57 <CommentTiny /> {props.commentsCount} 57 58 </SpeedyLink> 58 59 )} 59 - {interactionsAvailable && props.share ? ( 60 - <Separator classname="h-4! !min-h-0" /> 61 - ) : null} 60 + {tagsCount === 0 ? null : ( 61 + <> 62 + {interactionsAvailable ? <Separator classname="h-4!" /> : null} 63 + <TagPopover tags={props.tags!} /> 64 + </> 65 + )} 62 66 {props.share && ( 63 67 <> 68 + <Separator classname="h-4!" /> 69 + 64 70 <button 65 71 id={`copy-post-link-${props.postUrl}`} 66 72 className="flex gap-1 items-center hover:text-accent-contrast relative"
+43 -25
components/Pages/PublicationMetadata.tsx
··· 20 20 import { useIdentityData } from "components/IdentityProvider"; 21 21 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22 22 import { Backdater } from "./Backdater"; 23 + import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; 23 24 24 - export const PublicationMetadata = () => { 25 + export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 25 26 let { rep } = useReplicache(); 26 - let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 27 + let { 28 + data: pub, 29 + normalizedDocument, 30 + normalizedPublication, 31 + } = useLeafletPublicationData(); 27 32 let { identity } = useIdentityData(); 28 33 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 29 34 let description = useSubscribe(rep, (tx) => ··· 114 119 ) : ( 115 120 <p>Draft</p> 116 121 )} 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> 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 + )} 138 153 </> 139 154 } 140 155 /> ··· 238 253 ); 239 254 }; 240 255 241 - const AddTags = () => { 256 + export const AddTags = () => { 242 257 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 243 258 let { rep } = useReplicache(); 244 259 ··· 251 266 let tags: string[] = []; 252 267 if (Array.isArray(replicacheTags)) { 253 268 tags = replicacheTags; 254 - } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 269 + } else if ( 270 + normalizedDocument?.tags && 271 + Array.isArray(normalizedDocument.tags) 272 + ) { 255 273 tags = normalizedDocument.tags as string[]; 256 274 } 257 275
+9 -1
components/PostListing.tsx
··· 53 53 pubRecord?.preferences?.showComments === false 54 54 ? 0 55 55 : props.documents.comments_on_documents?.[0]?.count || 0; 56 + let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 56 57 let tags = (postRecord?.tags as string[] | undefined) || []; 57 58 58 59 // For standalone posts, link directly to the document ··· 88 89 > 89 90 <h3 className="text-primary truncate">{postRecord.title}</h3> 90 91 91 - <p className="text-secondary italic">{postRecord.description}</p> 92 + <p className="text-secondary italic line-clamp-3"> 93 + {postRecord.description} 94 + </p> 92 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"> 93 96 {props.publication && pubRecord && ( 94 97 <PubInfo ··· 103 106 postUrl={postHref} 104 107 quotesCount={quotes} 105 108 commentsCount={comments} 109 + recommendsCount={recommends} 110 + documentUri={props.documents.uri} 106 111 tags={tags} 107 112 showComments={pubRecord?.preferences?.showComments !== false} 108 113 showMentions={pubRecord?.preferences?.showMentions !== false} 114 + showRecommends={ 115 + pubRecord?.preferences?.showRecommends !== false 116 + } 109 117 share 110 118 /> 111 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 21 | "comments" 22 22 | "mentions" 23 23 | "leafletId" 24 + | "recommendsCount" 24 25 >; 25 26 26 27 const DocumentContext = createContext<DocumentContextValue | null>(null);
+44 -39
lexicons/api/types/pub/leaflet/publication.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 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' 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"; 10 14 11 15 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'pub.leaflet.publication' 16 + validate = _validate; 17 + const id = "pub.leaflet.publication"; 14 18 15 19 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 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; 24 28 } 25 29 26 - const hashRecord = 'main' 30 + const hashRecord = "main"; 27 31 28 32 export function isRecord<V>(v: V) { 29 - return is$typed(v, id, hashRecord) 33 + return is$typed(v, id, hashRecord); 30 34 } 31 35 32 36 export function validateRecord<V>(v: V) { 33 - return validate<Record & V>(v, id, hashRecord, true) 37 + return validate<Record & V>(v, id, hashRecord, true); 34 38 } 35 39 36 40 export interface Preferences { 37 - $type?: 'pub.leaflet.publication#preferences' 38 - showInDiscover: boolean 39 - showComments: boolean 40 - showMentions: boolean 41 - showPrevNext: boolean 41 + $type?: "pub.leaflet.publication#preferences"; 42 + showInDiscover: boolean; 43 + showComments: boolean; 44 + showMentions: boolean; 45 + showPrevNext: boolean; 46 + showRecommends: boolean; 42 47 } 43 48 44 - const hashPreferences = 'preferences' 49 + const hashPreferences = "preferences"; 45 50 46 51 export function isPreferences<V>(v: V) { 47 - return is$typed(v, id, hashPreferences) 52 + return is$typed(v, id, hashPreferences); 48 53 } 49 54 50 55 export function validatePreferences<V>(v: V) { 51 - return validate<Preferences & V>(v, id, hashPreferences) 56 + return validate<Preferences & V>(v, id, hashPreferences); 52 57 } 53 58 54 59 export interface Theme { 55 - $type?: 'pub.leaflet.publication#theme' 60 + $type?: "pub.leaflet.publication#theme"; 56 61 backgroundColor?: 57 62 | $Typed<PubLeafletThemeColor.Rgba> 58 63 | $Typed<PubLeafletThemeColor.Rgb> 59 - | { $type: string } 60 - backgroundImage?: PubLeafletThemeBackgroundImage.Main 61 - pageWidth?: number 64 + | { $type: string }; 65 + backgroundImage?: PubLeafletThemeBackgroundImage.Main; 66 + pageWidth?: number; 62 67 primary?: 63 68 | $Typed<PubLeafletThemeColor.Rgba> 64 69 | $Typed<PubLeafletThemeColor.Rgb> 65 - | { $type: string } 70 + | { $type: string }; 66 71 pageBackground?: 67 72 | $Typed<PubLeafletThemeColor.Rgba> 68 73 | $Typed<PubLeafletThemeColor.Rgb> 69 - | { $type: string } 70 - showPageBackground: boolean 74 + | { $type: string }; 75 + showPageBackground: boolean; 71 76 accentBackground?: 72 77 | $Typed<PubLeafletThemeColor.Rgba> 73 78 | $Typed<PubLeafletThemeColor.Rgb> 74 - | { $type: string } 79 + | { $type: string }; 75 80 accentText?: 76 81 | $Typed<PubLeafletThemeColor.Rgba> 77 82 | $Typed<PubLeafletThemeColor.Rgb> 78 - | { $type: string } 83 + | { $type: string }; 79 84 } 80 85 81 - const hashTheme = 'theme' 86 + const hashTheme = "theme"; 82 87 83 88 export function isTheme<V>(v: V) { 84 - return is$typed(v, id, hashTheme) 89 + return is$typed(v, id, hashTheme); 85 90 } 86 91 87 92 export function validateTheme<V>(v: V) { 88 - return validate<Theme & V>(v, id, hashTheme) 93 + return validate<Theme & V>(v, id, hashTheme); 89 94 }
+33 -28
lexicons/api/types/site/standard/publication.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 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' 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"; 10 14 11 15 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'site.standard.publication' 16 + validate = _validate; 17 + const id = "site.standard.publication"; 14 18 15 19 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 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; 25 29 } 26 30 27 - const hashRecord = 'main' 31 + const hashRecord = "main"; 28 32 29 33 export function isRecord<V>(v: V) { 30 - return is$typed(v, id, hashRecord) 34 + return is$typed(v, id, hashRecord); 31 35 } 32 36 33 37 export function validateRecord<V>(v: V) { 34 - return validate<Record & V>(v, id, hashRecord, true) 38 + return validate<Record & V>(v, id, hashRecord, true); 35 39 } 36 40 37 41 export interface Preferences { 38 - $type?: 'site.standard.publication#preferences' 39 - showInDiscover: boolean 40 - showComments: boolean 41 - showMentions: boolean 42 - showPrevNext: boolean 42 + $type?: "site.standard.publication#preferences"; 43 + showInDiscover: boolean; 44 + showComments: boolean; 45 + showMentions: boolean; 46 + showPrevNext: boolean; 47 + showRecommends: boolean; 43 48 } 44 49 45 - const hashPreferences = 'preferences' 50 + const hashPreferences = "preferences"; 46 51 47 52 export function isPreferences<V>(v: V) { 48 - return is$typed(v, id, hashPreferences) 53 + return is$typed(v, id, hashPreferences); 49 54 } 50 55 51 56 export function validatePreferences<V>(v: V) { 52 - return validate<Preferences & V>(v, id, hashPreferences) 57 + return validate<Preferences & V>(v, id, hashPreferences); 53 58 }
+4
lexicons/pub/leaflet/publication.json
··· 59 59 "showPrevNext": { 60 60 "type": "boolean", 61 61 "default": true 62 + }, 63 + "showRecommends": { 64 + "type": "boolean", 65 + "default": true 62 66 } 63 67 } 64 68 },
+4
lexicons/site/standard/publication.json
··· 58 58 "showPrevNext": { 59 59 "default": false, 60 60 "type": "boolean" 61 + }, 62 + "showRecommends": { 63 + "default": true, 64 + "type": "boolean" 61 65 } 62 66 }, 63 67 "type": "object"
+16 -11
lexicons/src/normalize.ts
··· 50 50 * Checks if the record is a pub.leaflet.document 51 51 */ 52 52 export function isLeafletDocument( 53 - record: unknown 53 + record: unknown, 54 54 ): record is PubLeafletDocument.Record { 55 55 if (!record || typeof record !== "object") return false; 56 56 const r = record as Record<string, unknown>; ··· 65 65 * Checks if the record is a site.standard.document 66 66 */ 67 67 export function isStandardDocument( 68 - record: unknown 68 + record: unknown, 69 69 ): record is SiteStandardDocument.Record { 70 70 if (!record || typeof record !== "object") return false; 71 71 const r = record as Record<string, unknown>; ··· 76 76 * Checks if the record is a pub.leaflet.publication 77 77 */ 78 78 export function isLeafletPublication( 79 - record: unknown 79 + record: unknown, 80 80 ): record is PubLeafletPublication.Record { 81 81 if (!record || typeof record !== "object") return false; 82 82 const r = record as Record<string, unknown>; ··· 91 91 * Checks if the record is a site.standard.publication 92 92 */ 93 93 export function isStandardPublication( 94 - record: unknown 94 + record: unknown, 95 95 ): record is SiteStandardPublication.Record { 96 96 if (!record || typeof record !== "object") return false; 97 97 const r = record as Record<string, unknown>; ··· 106 106 | $Typed<PubLeafletThemeColor.Rgba> 107 107 | $Typed<PubLeafletThemeColor.Rgb> 108 108 | { $type: string } 109 - | undefined 109 + | undefined, 110 110 ): { r: number; g: number; b: number } | undefined { 111 111 if (!color || typeof color !== "object") return undefined; 112 112 const c = color as Record<string, unknown>; ··· 124 124 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 125 */ 126 126 export function leafletThemeToBasicTheme( 127 - theme: PubLeafletPublication.Theme | undefined 127 + theme: PubLeafletPublication.Theme | undefined, 128 128 ): SiteStandardThemeBasic.Main | undefined { 129 129 if (!theme) return undefined; 130 130 131 131 const background = extractRgb(theme.backgroundColor); 132 - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 132 + const accent = 133 + extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 134 const accentForeground = extractRgb(theme.accentText); 134 135 135 136 // If we don't have the required colors, return undefined ··· 160 161 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 162 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 163 */ 163 - export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 164 + export function normalizeDocument( 165 + record: unknown, 166 + uri?: string, 167 + ): NormalizedDocument | null { 164 168 if (!record || typeof record !== "object") return null; 165 169 166 170 // Pass through site.standard records directly (theme is already in correct format if present) ··· 219 223 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 224 */ 221 225 export function normalizePublication( 222 - record: unknown 226 + record: unknown, 223 227 ): NormalizedPublication | null { 224 228 if (!record || typeof record !== "object") return null; 225 229 ··· 268 272 showComments: record.preferences.showComments, 269 273 showMentions: record.preferences.showMentions, 270 274 showPrevNext: record.preferences.showPrevNext, 275 + showRecommends: record.preferences.showRecommends, 271 276 } 272 277 : undefined; 273 278 ··· 290 295 * Type guard to check if a normalized document has leaflet content 291 296 */ 292 297 export function hasLeafletContent( 293 - doc: NormalizedDocument 298 + doc: NormalizedDocument, 294 299 ): doc is NormalizedDocument & { 295 300 content: $Typed<PubLeafletContent.Main>; 296 301 } { ··· 304 309 * Gets the pages array from a normalized document, handling both formats 305 310 */ 306 311 export function getDocumentPages( 307 - doc: NormalizedDocument 312 + doc: NormalizedDocument, 308 313 ): PubLeafletContent.Main["pages"] | undefined { 309 314 if (!doc.content) return undefined; 310 315
+1
lexicons/src/publication.ts
··· 29 29 showComments: { type: "boolean", default: true }, 30 30 showMentions: { type: "boolean", default: true }, 31 31 showPrevNext: { type: "boolean", default: true }, 32 + showRecommends: { type: "boolean", default: true }, 32 33 }, 33 34 }, 34 35 theme: {
+16
package-lock.json
··· 38 38 "@vercel/analytics": "^1.5.0", 39 39 "@vercel/functions": "^2.2.12", 40 40 "@vercel/sdk": "^1.11.4", 41 + "@yornaath/batshit": "^0.14.0", 41 42 "babel-plugin-react-compiler": "^19.1.0-rc.1", 42 43 "base64-js": "^1.5.1", 43 44 "colorjs.io": "^0.5.2", ··· 8660 8661 "optional": true 8661 8662 } 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" 8663 8679 }, 8664 8680 "node_modules/abort-controller": { 8665 8681 "version": "3.0.0",
+1
package.json
··· 49 49 "@vercel/analytics": "^1.5.0", 50 50 "@vercel/functions": "^2.2.12", 51 51 "@vercel/sdk": "^1.11.4", 52 + "@yornaath/batshit": "^0.14.0", 52 53 "babel-plugin-react-compiler": "^19.1.0-rc.1", 53 54 "base64-js": "^1.5.1", 54 55 "colorjs.io": "^0.5.2",