a tool for shared writing and social publishing

Merge branch 'main' into update/reader

+1202 -946
+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.
+21
actions/publishToPublication.ts
··· 78 78 cover_image, 79 79 entitiesToDelete, 80 80 publishedAt, 81 + postPreferences, 81 82 }: { 82 83 root_entity: string; 83 84 publication_uri?: string; ··· 88 89 cover_image?: string | null; 89 90 entitiesToDelete?: string[]; 90 91 publishedAt?: string; 92 + postPreferences?: { 93 + showComments?: boolean; 94 + showMentions?: boolean; 95 + showRecommends?: boolean; 96 + } | null; 91 97 }): Promise<PublishResult> { 92 98 let identity = await getIdentityData(); 93 99 if (!identity || !identity.atp_did) { ··· 175 181 }; 176 182 } 177 183 184 + // Resolve preferences: explicit param > draft DB value 185 + const preferences = postPreferences ?? draft?.preferences; 186 + 178 187 // Extract theme for standalone documents (not for publications) 179 188 let theme: PubLeafletPublication.Theme | undefined; 180 189 if (!publication_uri) { ··· 245 254 ...(coverImageBlob && { coverImage: coverImageBlob }), 246 255 // Include theme for standalone documents (not for publication documents) 247 256 ...(!publication_uri && theme && { theme }), 257 + ...(preferences && { 258 + preferences: { 259 + $type: "pub.leaflet.publication#preferences" as const, 260 + ...preferences, 261 + }, 262 + }), 248 263 content: { 249 264 $type: "pub.leaflet.content" as const, 250 265 pages: pagesArray, ··· 257 272 author: credentialSession.did!, 258 273 ...(publication_uri && { publication: publication_uri }), 259 274 ...(theme && { theme }), 275 + ...(preferences && { 276 + preferences: { 277 + $type: "pub.leaflet.publication#preferences" as const, 278 + ...preferences, 279 + }, 280 + }), 260 281 title: title || "Untitled", 261 282 description: description || "", 262 283 ...(tags !== undefined && { tags }),
+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
app/[leaflet_id]/Footer.tsx
··· 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { useEntity } from "src/replicache"; 16 16 import { block } from "sharp"; 17 + import { PostSettings } from "components/PostSettings"; 17 18 18 19 export function hasBlockToolbar(blockType: string | null | undefined) { 19 20 return ( ··· 67 68 <div className="mobileLeafletActions flex gap-2 shrink-0"> 68 69 <PublishButton entityID={props.entityID} /> 69 70 <ShareOptions /> 71 + <PostSettings /> 70 72 <ThemePopover entityID={props.entityID} /> 71 73 </div> 72 74 </Footer>
+2
app/[leaflet_id]/Sidebar.tsx
··· 8 8 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 9 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 10 import { PublishButton } from "./actions/PublishButton"; 11 + import { PostSettings } from "components/PostSettings"; 11 12 import { Watermark } from "components/Watermark"; 12 13 import { BackToPubButton } from "./actions/BackToPubButton"; 13 14 import { useIdentityData } from "components/IdentityProvider"; ··· 30 31 <Sidebar> 31 32 <PublishButton entityID={rootEntity} /> 32 33 <ShareOptions /> 34 + <PostSettings /> 33 35 <ThemePopover entityID={rootEntity} /> 34 36 <HelpButton /> 35 37 <hr className="text-border" />
+10
app/[leaflet_id]/actions/PublishButton.tsx
··· 96 96 tx.get<string | null>("publication_cover_image"), 97 97 ); 98 98 99 + // Get post preferences from Replicache state 100 + let postPreferences = useSubscribe(rep, (tx) => 101 + tx.get<{ 102 + showComments?: boolean; 103 + showMentions?: boolean; 104 + showRecommends?: boolean; 105 + } | null>("post_preferences"), 106 + ); 107 + 99 108 // Get local published at from Replicache (session-only state, not persisted to DB) 100 109 let publishedAt = useLocalPublishedAt((s) => 101 110 pub?.doc ? s[pub?.doc] : undefined, ··· 118 127 tags: currentTags, 119 128 cover_image: coverImage, 120 129 publishedAt: publishedAt?.toISOString(), 130 + postPreferences, 121 131 }); 122 132 setIsLoading(false); 123 133 mutate();
+10
app/[leaflet_id]/publish/PublishPost.tsx
··· 91 91 tx.get<string | null>("publication_cover_image"), 92 92 ); 93 93 94 + // Get post preferences from Replicache state 95 + let postPreferences = useSubscribe(rep, (tx) => 96 + tx.get<{ 97 + showComments?: boolean; 98 + showMentions?: boolean; 99 + showRecommends?: boolean; 100 + } | null>("post_preferences"), 101 + ); 102 + 94 103 // Use Replicache tags only when we have a draft 95 104 const currentTags = props.hasDraft 96 105 ? Array.isArray(replicacheTags) ··· 124 133 cover_image: replicacheCoverImage, 125 134 entitiesToDelete: props.entitiesToDelete, 126 135 publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 136 + postPreferences, 127 137 }); 128 138 129 139 if (!result.success) {
+2 -1
app/api/oauth/[route]/oauth-metadata.ts
··· 7 7 ? "http://localhost:3000" 8 8 : "https://leaflet.pub"; 9 9 10 - const scope = "atproto transition:generic transition:email"; 10 + const scope = 11 + "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*"; 11 12 const localconfig: OAuthClientMetadataInput = { 12 13 client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`, 13 14 client_name: `Leaflet`,
+4 -2
app/api/oauth/[route]/route.ts
··· 42 42 const ac = new AbortController(); 43 43 44 44 const url = await client.authorize(handle || "https://bsky.social", { 45 - scope: "atproto transition:generic transition:email", 45 + scope: 46 + "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*", 46 47 signal: ac.signal, 47 48 state: JSON.stringify(state), 48 49 }); ··· 105 106 }) 106 107 .select() 107 108 .single(); 108 - 109 + console.log({ token }); 109 110 if (token) await setAuthToken(token.id); 110 111 111 112 // Process successful authentication here ··· 114 115 console.log("User authenticated as:", session.did); 115 116 return handleAction(s.action, redirectPath); 116 117 } catch (e) { 118 + console.log(e); 117 119 redirect(redirectPath); 118 120 } 119 121 }
+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 + });
+7
app/api/rpc/[command]/pull.ts
··· 9 9 import type { Attribute } from "src/replicache/attributes"; 10 10 import { makeRoute } from "../lib"; 11 11 import type { Env } from "./route"; 12 + import type { Json } from "supabase/database.types"; 12 13 13 14 // First define the sub-types for V0 and V1 requests 14 15 const pullRequestV0 = z.object({ ··· 75 76 title: string; 76 77 tags: string[]; 77 78 cover_image: string | null; 79 + preferences: Json | null; 78 80 }[]; 79 81 let pub_patch = publication_data?.[0] 80 82 ? [ ··· 97 99 op: "put", 98 100 key: "publication_cover_image", 99 101 value: publication_data[0].cover_image || null, 102 + }, 103 + { 104 + op: "put", 105 + key: "post_preferences", 106 + value: publication_data[0].preferences || null, 100 107 }, 101 108 ] 102 109 : [];
+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 </>
+2 -1
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 21 21 } from "src/utils/normalizeRecords"; 22 22 import { DocumentProvider } from "contexts/DocumentContext"; 23 23 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 24 + import { mergePreferences } from "src/utils/mergePreferences"; 24 25 25 26 export async function DocumentPageRenderer({ 26 27 did, ··· 133 134 <LeafletLayout> 134 135 <PostPages 135 136 document_uri={document.uri} 136 - preferences={pubRecord?.preferences || {}} 137 + preferences={mergePreferences(record?.preferences, pubRecord?.preferences)} 137 138 pubRecord={pubRecord} 138 139 profile={JSON.parse(JSON.stringify(profile.data))} 139 140 document={document}
+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 />
+6 -4
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[]; ··· 293 295 showPageBackground={pubRecord?.theme?.showPageBackground} 294 296 document_uri={document.uri} 295 297 comments={ 296 - pubRecord?.preferences?.showComments === false 298 + preferences.showComments === false 297 299 ? [] 298 300 : document.comments_on_documents 299 301 } 300 302 quotesAndMentions={ 301 - pubRecord?.preferences?.showMentions === false 303 + preferences.showMentions === false 302 304 ? [] 303 305 : quotesAndMentions 304 306 } ··· 385 387 pageId={page.id} 386 388 document_uri={document.uri} 387 389 comments={ 388 - pubRecord?.preferences?.showComments === false 390 + preferences.showComments === false 389 391 ? [] 390 392 : document.comments_on_documents 391 393 } 392 394 quotesAndMentions={ 393 - pubRecord?.preferences?.showMentions === false 395 + preferences.showMentions === false 394 396 ? [] 395 397 : quotesAndMentions 396 398 }
+5 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 20 20 publication_subscriptions(*)) 21 21 ), 22 22 document_mentions_in_bsky(*), 23 - leaflets_in_publications(*) 23 + leaflets_in_publications(*), 24 + recommends_on_documents(count) 24 25 `, 25 26 ) 26 27 .or(documentUriFilter(did, rkey)) ··· 140 141 publication_subscriptions: rawPub.publication_subscriptions || [], 141 142 } 142 143 : null; 144 + const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 143 145 144 146 return { 145 147 ...document, ··· 154 156 comments: document.comments_on_documents, 155 157 mentions: document.document_mentions_in_bsky, 156 158 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 159 + // Recommends data 160 + recommendsCount, 157 161 }; 158 162 } 159 163
+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 of 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 }
+33 -5
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"; 27 + import { useSubscribe } from "src/replicache/useSubscribe"; 28 + import { mergePreferences } from "src/utils/mergePreferences"; 26 29 27 30 export function Canvas(props: { 28 31 entityID: string; ··· 163 166 164 167 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 165 168 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 169 + let { rep } = useReplicache(); 170 + let postPreferences = useSubscribe(rep, (tx) => 171 + tx.get<{ 172 + showComments?: boolean; 173 + showMentions?: boolean; 174 + showRecommends?: boolean; 175 + } | null>("post_preferences"), 176 + ); 166 177 if (!pub || !pub.publications) return null; 167 178 168 179 if (!normalizedPublication) return null; 169 - let showComments = normalizedPublication.preferences?.showComments !== false; 170 - let showMentions = normalizedPublication.preferences?.showMentions !== false; 180 + let merged = mergePreferences( 181 + postPreferences || undefined, 182 + normalizedPublication.preferences, 183 + ); 184 + let showComments = merged.showComments !== false; 185 + let showMentions = merged.showMentions !== false; 186 + let showRecommends = merged.showRecommends !== false; 171 187 172 188 return ( 173 189 <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"> 190 + {showRecommends && ( 191 + <div className="flex gap-1 text-tertiary items-center"> 192 + <RecommendTinyEmpty className="text-border" /> — 193 + </div> 194 + )} 174 195 {showComments && ( 175 196 <div className="flex gap-1 text-tertiary items-center"> 176 197 <CommentTiny className="text-border" /> — 177 198 </div> 178 199 )} 179 - {showComments && ( 200 + {showMentions && ( 180 201 <div className="flex gap-1 text-tertiary items-center"> 181 202 <QuoteTiny className="text-border" /> — 182 203 </div> 183 204 )} 184 205 206 + {showMentions !== false || 207 + showComments !== false || 208 + showRecommends === false ? ( 209 + <Separator classname="h-4!" /> 210 + ) : null} 211 + <AddTags /> 212 + 185 213 {!props.isSubpage && ( 186 214 <> 187 215 <Separator classname="h-5" /> ··· 191 219 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 220 trigger={<InfoSmall />} 193 221 > 194 - <PublicationMetadata /> 222 + <PublicationMetadata noInteractions /> 195 223 </Popover> 196 224 </> 197 225 )}
+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"
+54 -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"; 24 + import { mergePreferences } from "src/utils/mergePreferences"; 23 25 24 - export const PublicationMetadata = () => { 26 + export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 25 27 let { rep } = useReplicache(); 26 - let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 28 + let { 29 + data: pub, 30 + normalizedDocument, 31 + normalizedPublication, 32 + } = useLeafletPublicationData(); 27 33 let { identity } = useIdentityData(); 28 34 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 29 35 let description = useSubscribe(rep, (tx) => 30 36 tx.get<string>("publication_description"), 37 + ); 38 + let postPreferences = useSubscribe(rep, (tx) => 39 + tx.get<{ 40 + showComments?: boolean; 41 + showMentions?: boolean; 42 + showRecommends?: boolean; 43 + } | null>("post_preferences"), 44 + ); 45 + let merged = mergePreferences( 46 + postPreferences || undefined, 47 + normalizedPublication?.preferences, 31 48 ); 32 49 let publishedAt = normalizedDocument?.publishedAt; 33 50 ··· 114 131 ) : ( 115 132 <p>Draft</p> 116 133 )} 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> 134 + {!props.noInteractions && ( 135 + <div className="flex gap-2 text-border items-center"> 136 + {merged.showRecommends !== false && ( 137 + <div className="flex gap-1 items-center"> 138 + <RecommendTinyEmpty />— 139 + </div> 140 + )} 141 + 142 + {merged.showMentions !== false && ( 143 + <div className="flex gap-1 items-center"> 144 + <QuoteTiny />— 145 + </div> 146 + )} 147 + {merged.showComments !== false && ( 148 + <div className="flex gap-1 items-center"> 149 + <CommentTiny />— 150 + </div> 151 + )} 152 + {tags && ( 153 + <> 154 + {merged.showRecommends !== false || 155 + merged.showMentions !== false || 156 + merged.showComments !== false ? ( 157 + <Separator classname="h-4!" /> 158 + ) : null} 159 + <AddTags /> 160 + </> 161 + )} 162 + </div> 163 + )} 138 164 </> 139 165 } 140 166 /> ··· 238 264 ); 239 265 }; 240 266 241 - const AddTags = () => { 267 + export const AddTags = () => { 242 268 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 243 269 let { rep } = useReplicache(); 244 270 ··· 251 277 let tags: string[] = []; 252 278 if (Array.isArray(replicacheTags)) { 253 279 tags = replicacheTags; 254 - } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 280 + } else if ( 281 + normalizedDocument?.tags && 282 + Array.isArray(normalizedDocument.tags) 283 + ) { 255 284 tags = normalizedDocument.tags as string[]; 256 285 } 257 286
+10 -3
components/PostListing.tsx
··· 19 19 import { QuoteTiny } from "./Icons/QuoteTiny"; 20 20 import { ShareTiny } from "./Icons/ShareTiny"; 21 21 import { useSelectedPostListing } from "src/useSelectedPostState"; 22 + import { mergePreferences } from "src/utils/mergePreferences"; 22 23 23 24 export const PostListing = (props: Post) => { 24 25 let pubRecord = props.publication?.pubRecord as ··· 58 59 ? pubRecord?.theme?.showPageBackground 59 60 : postRecord.theme?.showPageBackground ?? true; 60 61 62 + let mergedPrefs = mergePreferences( 63 + postRecord?.preferences, 64 + pubRecord?.preferences, 65 + ); 66 + 61 67 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 62 68 let comments = 63 - pubRecord?.preferences?.showComments === false 69 + mergedPrefs.showComments === false 64 70 ? 0 65 71 : props.documents.comments_on_documents?.[0]?.count || 0; 72 + let recommends = props.documents.recommends_on_documents?.[0]?.count || 0; 66 73 let tags = (postRecord?.tags as string[] | undefined) || []; 67 74 68 75 // For standalone posts, link directly to the document ··· 140 147 quotesCount={quotes} 141 148 commentsCount={comments} 142 149 tags={tags} 143 - showComments={pubRecord?.preferences?.showComments !== false} 144 - showMentions={pubRecord?.preferences?.showMentions !== false} 150 + showComments={mergedPrefs.showComments !== false} 151 + showMentions={mergedPrefs.showMentions !== false} 145 152 documentUri={props.documents.uri} 146 153 document={postRecord} 147 154 />
+96
components/PostSettings.tsx
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 5 + import { Toggle } from "components/Toggle"; 6 + import { Popover } from "components/Popover"; 7 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { useReplicache } from "src/replicache"; 9 + import { useSubscribe } from "src/replicache/useSubscribe"; 10 + 11 + type PostPreferences = { 12 + showComments?: boolean; 13 + showMentions?: boolean; 14 + showRecommends?: boolean; 15 + }; 16 + 17 + export function PostSettings() { 18 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 19 + let { rep } = useReplicache(); 20 + 21 + let postPreferences = useSubscribe(rep, (tx) => 22 + tx.get<PostPreferences | null>("post_preferences"), 23 + ); 24 + 25 + if (!pub || !pub.publications) return null; 26 + 27 + let pubPrefs = normalizedPublication?.preferences; 28 + 29 + let showComments = 30 + postPreferences?.showComments ?? pubPrefs?.showComments ?? true; 31 + let showMentions = 32 + postPreferences?.showMentions ?? pubPrefs?.showMentions ?? true; 33 + let showRecommends = 34 + postPreferences?.showRecommends ?? pubPrefs?.showRecommends ?? true; 35 + 36 + const updatePreference = ( 37 + field: keyof PostPreferences, 38 + value: boolean, 39 + ) => { 40 + let current: PostPreferences = postPreferences || {}; 41 + rep?.mutate.updatePublicationDraft({ 42 + preferences: { ...current, [field]: value }, 43 + }); 44 + }; 45 + 46 + return ( 47 + <Popover 48 + asChild 49 + side="right" 50 + align="start" 51 + className="max-w-xs w-[1000px]" 52 + trigger={ 53 + <ActionButton 54 + icon={<SettingsSmall />} 55 + label="Settings" 56 + /> 57 + } 58 + > 59 + <div className="text-primary flex flex-col"> 60 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 61 + This Post Settings 62 + </div> 63 + <div className="flex flex-col gap-2"> 64 + <Toggle 65 + toggle={showComments} 66 + onToggle={() => updatePreference("showComments", !showComments)} 67 + > 68 + <div className="font-bold">Show Comments</div> 69 + </Toggle> 70 + <Toggle 71 + toggle={showMentions} 72 + onToggle={() => updatePreference("showMentions", !showMentions)} 73 + > 74 + <div className="flex flex-col justify-start"> 75 + <div className="font-bold">Show Mentions</div> 76 + <div className="text-tertiary text-sm leading-tight"> 77 + Display a list of Bluesky mentions about your post 78 + </div> 79 + </div> 80 + </Toggle> 81 + <Toggle 82 + toggle={showRecommends} 83 + onToggle={() => updatePreference("showRecommends", !showRecommends)} 84 + > 85 + <div className="flex flex-col justify-start"> 86 + <div className="font-bold">Show Recommends</div> 87 + <div className="text-tertiary text-sm leading-tight"> 88 + Allow readers to recommend/like your post 89 + </div> 90 + </div> 91 + </Toggle> 92 + </div> 93 + </div> 94 + </Popover> 95 + ); 96 + }
+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);
+17
lexicons/api/lexicons.ts
··· 1469 1469 type: 'ref', 1470 1470 ref: 'lex:pub.leaflet.publication#theme', 1471 1471 }, 1472 + preferences: { 1473 + type: 'ref', 1474 + ref: 'lex:pub.leaflet.publication#preferences', 1475 + }, 1472 1476 tags: { 1473 1477 type: 'array', 1474 1478 items: { ··· 1868 1872 type: 'boolean', 1869 1873 default: true, 1870 1874 }, 1875 + showRecommends: { 1876 + type: 'boolean', 1877 + default: true, 1878 + }, 1871 1879 }, 1872 1880 }, 1873 1881 theme: { ··· 2194 2202 maxLength: 5000, 2195 2203 type: 'string', 2196 2204 }, 2205 + preferences: { 2206 + type: 'union', 2207 + refs: ['lex:pub.leaflet.publication#preferences'], 2208 + closed: false, 2209 + }, 2197 2210 updatedAt: { 2198 2211 format: 'datetime', 2199 2212 type: 'string', ··· 2288 2301 }, 2289 2302 showPrevNext: { 2290 2303 default: false, 2304 + type: 'boolean', 2305 + }, 2306 + showRecommends: { 2307 + default: true, 2291 2308 type: 'boolean', 2292 2309 }, 2293 2310 },
+1
lexicons/api/types/pub/leaflet/document.ts
··· 23 23 publication?: string 24 24 author: string 25 25 theme?: PubLeafletPublication.Theme 26 + preferences?: PubLeafletPublication.Preferences 26 27 tags?: string[] 27 28 coverImage?: BlobRef 28 29 pages: (
+1
lexicons/api/types/pub/leaflet/publication.ts
··· 39 39 showComments: boolean 40 40 showMentions: boolean 41 41 showPrevNext: boolean 42 + showRecommends: boolean 42 43 } 43 44 44 45 const hashPreferences = 'preferences'
+1
lexicons/api/types/site/standard/document.ts
··· 28 28 textContent?: string 29 29 theme?: PubLeafletPublication.Theme 30 30 title: string 31 + preferences?: $Typed<PubLeafletPublication.Preferences> | { $type: string } 31 32 updatedAt?: string 32 33 [k: string]: unknown 33 34 }
+1
lexicons/api/types/site/standard/publication.ts
··· 40 40 showComments: boolean 41 41 showMentions: boolean 42 42 showPrevNext: boolean 43 + showRecommends: boolean 43 44 } 44 45 45 46 const hashPreferences = 'preferences'
+4
lexicons/pub/leaflet/document.json
··· 46 46 "type": "ref", 47 47 "ref": "pub.leaflet.publication#theme" 48 48 }, 49 + "preferences": { 50 + "type": "ref", 51 + "ref": "pub.leaflet.publication#preferences" 52 + }, 49 53 "tags": { 50 54 "type": "array", 51 55 "items": {
+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 },
+5
lexicons/site/standard/document.json
··· 57 57 "maxLength": 5000, 58 58 "type": "string" 59 59 }, 60 + "preferences": { 61 + "type": "union", 62 + "refs": ["pub.leaflet.publication#preferences"], 63 + "closed": false 64 + }, 60 65 "updatedAt": { 61 66 "format": "datetime", 62 67 "type": "string"
+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"
+4
lexicons/src/document.ts
··· 23 23 publication: { type: "string", format: "at-uri" }, 24 24 author: { type: "string", format: "at-identifier" }, 25 25 theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + preferences: { 27 + type: "ref", 28 + ref: "pub.leaflet.publication#preferences", 29 + }, 26 30 tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 31 coverImage: { 28 32 type: "blob",
+28 -11
lexicons/src/normalize.ts
··· 28 28 export type NormalizedDocument = SiteStandardDocument.Record & { 29 29 // Keep the original theme for components that need leaflet-specific styling 30 30 theme?: PubLeafletPublication.Theme; 31 + preferences?: SiteStandardPublication.Preferences; 31 32 }; 32 33 33 34 // Normalized publication type - uses the generated site.standard.publication type ··· 50 51 * Checks if the record is a pub.leaflet.document 51 52 */ 52 53 export function isLeafletDocument( 53 - record: unknown 54 + record: unknown, 54 55 ): record is PubLeafletDocument.Record { 55 56 if (!record || typeof record !== "object") return false; 56 57 const r = record as Record<string, unknown>; ··· 65 66 * Checks if the record is a site.standard.document 66 67 */ 67 68 export function isStandardDocument( 68 - record: unknown 69 + record: unknown, 69 70 ): record is SiteStandardDocument.Record { 70 71 if (!record || typeof record !== "object") return false; 71 72 const r = record as Record<string, unknown>; ··· 76 77 * Checks if the record is a pub.leaflet.publication 77 78 */ 78 79 export function isLeafletPublication( 79 - record: unknown 80 + record: unknown, 80 81 ): record is PubLeafletPublication.Record { 81 82 if (!record || typeof record !== "object") return false; 82 83 const r = record as Record<string, unknown>; ··· 91 92 * Checks if the record is a site.standard.publication 92 93 */ 93 94 export function isStandardPublication( 94 - record: unknown 95 + record: unknown, 95 96 ): record is SiteStandardPublication.Record { 96 97 if (!record || typeof record !== "object") return false; 97 98 const r = record as Record<string, unknown>; ··· 106 107 | $Typed<PubLeafletThemeColor.Rgba> 107 108 | $Typed<PubLeafletThemeColor.Rgb> 108 109 | { $type: string } 109 - | undefined 110 + | undefined, 110 111 ): { r: number; g: number; b: number } | undefined { 111 112 if (!color || typeof color !== "object") return undefined; 112 113 const c = color as Record<string, unknown>; ··· 124 125 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 126 */ 126 127 export function leafletThemeToBasicTheme( 127 - theme: PubLeafletPublication.Theme | undefined 128 + theme: PubLeafletPublication.Theme | undefined, 128 129 ): SiteStandardThemeBasic.Main | undefined { 129 130 if (!theme) return undefined; 130 131 131 132 const background = extractRgb(theme.backgroundColor); 132 - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 + const accent = 134 + extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 135 const accentForeground = extractRgb(theme.accentText); 134 136 135 137 // If we don't have the required colors, return undefined ··· 160 162 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 163 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 164 */ 163 - export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 165 + export function normalizeDocument( 166 + record: unknown, 167 + uri?: string, 168 + ): NormalizedDocument | null { 164 169 if (!record || typeof record !== "object") return null; 165 170 166 171 // Pass through site.standard records directly (theme is already in correct format if present) 167 172 if (isStandardDocument(record)) { 173 + const preferences = record.preferences as 174 + | SiteStandardPublication.Preferences 175 + | undefined; 168 176 return { 169 177 ...record, 170 178 theme: record.theme, 179 + preferences, 171 180 } as NormalizedDocument; 172 181 } 173 182 ··· 194 203 } 195 204 : undefined; 196 205 206 + // Extract preferences if present (available after lexicon rebuild) 207 + const leafletPrefs = (record as Record<string, unknown>) 208 + .preferences as SiteStandardPublication.Preferences | undefined; 209 + 197 210 return { 198 211 $type: "site.standard.document", 199 212 title: record.title, ··· 206 219 bskyPostRef: record.postRef, 207 220 content, 208 221 theme: record.theme, 222 + preferences: leafletPrefs 223 + ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const } 224 + : undefined, 209 225 }; 210 226 } 211 227 ··· 219 235 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 236 */ 221 237 export function normalizePublication( 222 - record: unknown 238 + record: unknown, 223 239 ): NormalizedPublication | null { 224 240 if (!record || typeof record !== "object") return null; 225 241 ··· 268 284 showComments: record.preferences.showComments, 269 285 showMentions: record.preferences.showMentions, 270 286 showPrevNext: record.preferences.showPrevNext, 287 + showRecommends: record.preferences.showRecommends, 271 288 } 272 289 : undefined; 273 290 ··· 290 307 * Type guard to check if a normalized document has leaflet content 291 308 */ 292 309 export function hasLeafletContent( 293 - doc: NormalizedDocument 310 + doc: NormalizedDocument, 294 311 ): doc is NormalizedDocument & { 295 312 content: $Typed<PubLeafletContent.Main>; 296 313 } { ··· 304 321 * Gets the pages array from a normalized document, handling both formats 305 322 */ 306 323 export function getDocumentPages( 307 - doc: NormalizedDocument 324 + doc: NormalizedDocument, 308 325 ): PubLeafletContent.Main["pages"] | undefined { 309 326 if (!doc.content) return undefined; 310 327
+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: {
+22 -391
package-lock.json
··· 16 16 "@atproto/oauth-client-node": "^0.3.8", 17 17 "@atproto/sync": "^0.1.34", 18 18 "@atproto/syntax": "^0.3.3", 19 - "@atproto/tap": "^0.1.1", 20 19 "@atproto/xrpc": "^0.7.5", 21 20 "@atproto/xrpc-server": "^0.9.5", 22 21 "@hono/node-server": "^1.14.3", ··· 38 37 "@vercel/analytics": "^1.5.0", 39 38 "@vercel/functions": "^2.2.12", 40 39 "@vercel/sdk": "^1.11.4", 40 + "@yornaath/batshit": "^0.14.0", 41 41 "babel-plugin-react-compiler": "^19.1.0-rc.1", 42 42 "base64-js": "^1.5.1", 43 43 "colorjs.io": "^0.5.2", ··· 395 395 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 396 396 "license": "(Apache-2.0 AND MIT)" 397 397 }, 398 - "node_modules/@atproto/lex": { 399 - "version": "0.0.9", 400 - "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz", 401 - "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==", 402 - "license": "MIT", 403 - "dependencies": { 404 - "@atproto/lex-builder": "0.0.9", 405 - "@atproto/lex-client": "0.0.7", 406 - "@atproto/lex-data": "0.0.6", 407 - "@atproto/lex-installer": "0.0.9", 408 - "@atproto/lex-json": "0.0.6", 409 - "@atproto/lex-schema": "0.0.7", 410 - "tslib": "^2.8.1", 411 - "yargs": "^17.0.0" 412 - }, 413 - "bin": { 414 - "lex": "bin/lex", 415 - "ts-lex": "bin/lex" 416 - } 417 - }, 418 - "node_modules/@atproto/lex-builder": { 419 - "version": "0.0.9", 420 - "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz", 421 - "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==", 422 - "license": "MIT", 423 - "dependencies": { 424 - "@atproto/lex-document": "0.0.8", 425 - "@atproto/lex-schema": "0.0.7", 426 - "prettier": "^3.2.5", 427 - "ts-morph": "^27.0.0", 428 - "tslib": "^2.8.1" 429 - } 430 - }, 431 - "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": { 432 - "version": "0.28.1", 433 - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", 434 - "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", 435 - "license": "MIT", 436 - "dependencies": { 437 - "minimatch": "^10.0.1", 438 - "path-browserify": "^1.0.1", 439 - "tinyglobby": "^0.2.14" 440 - } 441 - }, 442 - "node_modules/@atproto/lex-builder/node_modules/minimatch": { 443 - "version": "10.1.1", 444 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", 445 - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", 446 - "license": "BlueOak-1.0.0", 447 - "dependencies": { 448 - "@isaacs/brace-expansion": "^5.0.0" 449 - }, 450 - "engines": { 451 - "node": "20 || >=22" 452 - }, 453 - "funding": { 454 - "url": "https://github.com/sponsors/isaacs" 455 - } 456 - }, 457 - "node_modules/@atproto/lex-builder/node_modules/ts-morph": { 458 - "version": "27.0.2", 459 - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", 460 - "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", 461 - "license": "MIT", 462 - "dependencies": { 463 - "@ts-morph/common": "~0.28.1", 464 - "code-block-writer": "^13.0.3" 465 - } 466 - }, 467 - "node_modules/@atproto/lex-cbor": { 468 - "version": "0.0.6", 469 - "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", 470 - "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", 471 - "license": "MIT", 472 - "dependencies": { 473 - "@atproto/lex-data": "0.0.6", 474 - "multiformats": "^9.9.0", 475 - "tslib": "^2.8.1" 476 - } 477 - }, 478 - "node_modules/@atproto/lex-cbor/node_modules/multiformats": { 479 - "version": "9.9.0", 480 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 481 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 482 - "license": "(Apache-2.0 AND MIT)" 483 - }, 484 398 "node_modules/@atproto/lex-cli": { 485 399 "version": "0.9.5", 486 400 "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz", ··· 514 428 "tslib": "^2.8.1" 515 429 } 516 430 }, 517 - "node_modules/@atproto/lex-client": { 518 - "version": "0.0.7", 519 - "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz", 520 - "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==", 521 - "license": "MIT", 522 - "dependencies": { 523 - "@atproto/lex-data": "0.0.6", 524 - "@atproto/lex-json": "0.0.6", 525 - "@atproto/lex-schema": "0.0.7", 526 - "tslib": "^2.8.1" 527 - } 528 - }, 529 - "node_modules/@atproto/lex-data": { 530 - "version": "0.0.6", 531 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", 532 - "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", 533 - "license": "MIT", 534 - "dependencies": { 535 - "@atproto/syntax": "0.4.2", 536 - "multiformats": "^9.9.0", 537 - "tslib": "^2.8.1", 538 - "uint8arrays": "3.0.0", 539 - "unicode-segmenter": "^0.14.0" 540 - } 541 - }, 542 - "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": { 543 - "version": "0.4.2", 544 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 545 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 546 - "license": "MIT" 547 - }, 548 - "node_modules/@atproto/lex-data/node_modules/multiformats": { 549 - "version": "9.9.0", 550 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 551 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 552 - "license": "(Apache-2.0 AND MIT)" 553 - }, 554 - "node_modules/@atproto/lex-document": { 555 - "version": "0.0.8", 556 - "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz", 557 - "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==", 558 - "license": "MIT", 559 - "dependencies": { 560 - "@atproto/lex-schema": "0.0.7", 561 - "core-js": "^3", 562 - "tslib": "^2.8.1" 563 - } 564 - }, 565 - "node_modules/@atproto/lex-installer": { 566 - "version": "0.0.9", 567 - "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz", 568 - "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==", 569 - "license": "MIT", 570 - "dependencies": { 571 - "@atproto/lex-builder": "0.0.9", 572 - "@atproto/lex-cbor": "0.0.6", 573 - "@atproto/lex-data": "0.0.6", 574 - "@atproto/lex-document": "0.0.8", 575 - "@atproto/lex-resolver": "0.0.8", 576 - "@atproto/lex-schema": "0.0.7", 577 - "@atproto/syntax": "0.4.2", 578 - "tslib": "^2.8.1" 579 - } 580 - }, 581 - "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": { 582 - "version": "0.4.2", 583 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 584 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 585 - "license": "MIT" 586 - }, 587 - "node_modules/@atproto/lex-json": { 588 - "version": "0.0.6", 589 - "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz", 590 - "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==", 591 - "license": "MIT", 592 - "dependencies": { 593 - "@atproto/lex-data": "0.0.6", 594 - "tslib": "^2.8.1" 595 - } 596 - }, 597 - "node_modules/@atproto/lex-resolver": { 598 - "version": "0.0.8", 599 - "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz", 600 - "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==", 601 - "license": "MIT", 602 - "dependencies": { 603 - "@atproto-labs/did-resolver": "0.2.5", 604 - "@atproto/crypto": "0.4.5", 605 - "@atproto/lex-client": "0.0.7", 606 - "@atproto/lex-data": "0.0.6", 607 - "@atproto/lex-document": "0.0.8", 608 - "@atproto/lex-schema": "0.0.7", 609 - "@atproto/repo": "0.8.12", 610 - "@atproto/syntax": "0.4.2", 611 - "tslib": "^2.8.1" 612 - } 613 - }, 614 - "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": { 615 - "version": "0.2.5", 616 - "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", 617 - "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", 618 - "license": "MIT", 619 - "dependencies": { 620 - "@atproto-labs/fetch": "0.2.3", 621 - "@atproto-labs/pipe": "0.1.1", 622 - "@atproto-labs/simple-store": "0.3.0", 623 - "@atproto-labs/simple-store-memory": "0.1.4", 624 - "@atproto/did": "0.2.4", 625 - "zod": "^3.23.8" 626 - } 627 - }, 628 - "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": { 629 - "version": "0.2.4", 630 - "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz", 631 - "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==", 632 - "license": "MIT", 633 - "dependencies": { 634 - "zod": "^3.23.8" 635 - } 636 - }, 637 - "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": { 638 - "version": "0.4.2", 639 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 640 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 641 - "license": "MIT" 642 - }, 643 - "node_modules/@atproto/lex-schema": { 644 - "version": "0.0.7", 645 - "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz", 646 - "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==", 647 - "license": "MIT", 648 - "dependencies": { 649 - "@atproto/lex-data": "0.0.6", 650 - "@atproto/syntax": "0.4.2", 651 - "tslib": "^2.8.1" 652 - } 653 - }, 654 - "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": { 655 - "version": "0.4.2", 656 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 657 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 658 - "license": "MIT" 659 - }, 660 431 "node_modules/@atproto/lexicon": { 661 432 "version": "0.6.1", 662 433 "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", ··· 847 618 "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 848 619 "license": "MIT" 849 620 }, 850 - "node_modules/@atproto/tap": { 851 - "version": "0.1.1", 852 - "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz", 853 - "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==", 854 - "license": "MIT", 855 - "dependencies": { 856 - "@atproto/common": "^0.5.6", 857 - "@atproto/lex": "^0.0.9", 858 - "@atproto/syntax": "^0.4.2", 859 - "@atproto/ws-client": "^0.0.4", 860 - "ws": "^8.12.0", 861 - "zod": "^3.23.8" 862 - }, 863 - "engines": { 864 - "node": ">=18.7.0" 865 - } 866 - }, 867 - "node_modules/@atproto/tap/node_modules/@atproto/common": { 868 - "version": "0.5.10", 869 - "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz", 870 - "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==", 871 - "license": "MIT", 872 - "dependencies": { 873 - "@atproto/common-web": "^0.4.15", 874 - "@atproto/lex-cbor": "^0.0.10", 875 - "@atproto/lex-data": "^0.0.10", 876 - "iso-datestring-validator": "^2.2.2", 877 - "multiformats": "^9.9.0", 878 - "pino": "^8.21.0" 879 - }, 880 - "engines": { 881 - "node": ">=18.7.0" 882 - } 883 - }, 884 - "node_modules/@atproto/tap/node_modules/@atproto/lex-cbor": { 885 - "version": "0.0.10", 886 - "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz", 887 - "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==", 888 - "license": "MIT", 889 - "dependencies": { 890 - "@atproto/lex-data": "^0.0.10", 891 - "tslib": "^2.8.1" 892 - } 893 - }, 894 - "node_modules/@atproto/tap/node_modules/@atproto/lex-data": { 895 - "version": "0.0.10", 896 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz", 897 - "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==", 898 - "license": "MIT", 899 - "dependencies": { 900 - "multiformats": "^9.9.0", 901 - "tslib": "^2.8.1", 902 - "uint8arrays": "3.0.0", 903 - "unicode-segmenter": "^0.14.0" 904 - } 905 - }, 906 - "node_modules/@atproto/tap/node_modules/@atproto/syntax": { 907 - "version": "0.4.3", 908 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 909 - "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 910 - "license": "MIT", 911 - "dependencies": { 912 - "tslib": "^2.8.1" 913 - } 914 - }, 915 - "node_modules/@atproto/tap/node_modules/multiformats": { 916 - "version": "9.9.0", 917 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 918 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 919 - "license": "(Apache-2.0 AND MIT)" 920 - }, 921 - "node_modules/@atproto/ws-client": { 922 - "version": "0.0.4", 923 - "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz", 924 - "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==", 925 - "license": "MIT", 926 - "dependencies": { 927 - "@atproto/common": "^0.5.3", 928 - "ws": "^8.12.0" 929 - }, 930 - "engines": { 931 - "node": ">=18.7.0" 932 - } 933 - }, 934 - "node_modules/@atproto/ws-client/node_modules/@atproto/common": { 935 - "version": "0.5.10", 936 - "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz", 937 - "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==", 938 - "license": "MIT", 939 - "dependencies": { 940 - "@atproto/common-web": "^0.4.15", 941 - "@atproto/lex-cbor": "^0.0.10", 942 - "@atproto/lex-data": "^0.0.10", 943 - "iso-datestring-validator": "^2.2.2", 944 - "multiformats": "^9.9.0", 945 - "pino": "^8.21.0" 946 - }, 947 - "engines": { 948 - "node": ">=18.7.0" 949 - } 950 - }, 951 - "node_modules/@atproto/ws-client/node_modules/@atproto/lex-cbor": { 952 - "version": "0.0.10", 953 - "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz", 954 - "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==", 955 - "license": "MIT", 956 - "dependencies": { 957 - "@atproto/lex-data": "^0.0.10", 958 - "tslib": "^2.8.1" 959 - } 960 - }, 961 - "node_modules/@atproto/ws-client/node_modules/@atproto/lex-data": { 962 - "version": "0.0.10", 963 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz", 964 - "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==", 965 - "license": "MIT", 966 - "dependencies": { 967 - "multiformats": "^9.9.0", 968 - "tslib": "^2.8.1", 969 - "uint8arrays": "3.0.0", 970 - "unicode-segmenter": "^0.14.0" 971 - } 972 - }, 973 - "node_modules/@atproto/ws-client/node_modules/multiformats": { 974 - "version": "9.9.0", 975 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 976 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 977 - "license": "(Apache-2.0 AND MIT)" 978 - }, 979 621 "node_modules/@atproto/xrpc": { 980 622 "version": "0.7.5", 981 623 "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", ··· 3051 2693 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 3052 2694 "license": "(Apache-2.0 AND MIT)" 3053 2695 }, 3054 - "node_modules/@isaacs/balanced-match": { 3055 - "version": "4.0.1", 3056 - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", 3057 - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", 3058 - "license": "MIT", 3059 - "engines": { 3060 - "node": "20 || >=22" 3061 - } 3062 - }, 3063 - "node_modules/@isaacs/brace-expansion": { 3064 - "version": "5.0.0", 3065 - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", 3066 - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", 3067 - "license": "MIT", 3068 - "dependencies": { 3069 - "@isaacs/balanced-match": "^4.0.1" 3070 - }, 3071 - "engines": { 3072 - "node": "20 || >=22" 3073 - } 3074 - }, 3075 2696 "node_modules/@isaacs/fs-minipass": { 3076 2697 "version": "4.0.1", 3077 2698 "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", ··· 8661 8282 } 8662 8283 } 8663 8284 }, 8285 + "node_modules/@yornaath/batshit": { 8286 + "version": "0.14.0", 8287 + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", 8288 + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", 8289 + "license": "MIT", 8290 + "dependencies": { 8291 + "@yornaath/batshit-devtools": "^1.7.1" 8292 + } 8293 + }, 8294 + "node_modules/@yornaath/batshit-devtools": { 8295 + "version": "1.7.1", 8296 + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", 8297 + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", 8298 + "license": "MIT" 8299 + }, 8664 8300 "node_modules/abort-controller": { 8665 8301 "version": "3.0.0", 8666 8302 "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", ··· 9615 9251 "version": "13.0.3", 9616 9252 "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 9617 9253 "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 9254 + "dev": true, 9618 9255 "license": "MIT" 9619 9256 }, 9620 9257 "node_modules/collapse-white-space": { ··· 9722 9359 "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 9723 9360 "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 9724 9361 "license": "MIT" 9725 - }, 9726 - "node_modules/core-js": { 9727 - "version": "3.47.0", 9728 - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", 9729 - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", 9730 - "hasInstallScript": true, 9731 - "license": "MIT", 9732 - "funding": { 9733 - "type": "opencollective", 9734 - "url": "https://opencollective.com/core-js" 9735 - } 9736 9362 }, 9737 9363 "node_modules/crelt": { 9738 9364 "version": "1.0.6", ··· 16126 15752 "version": "1.0.1", 16127 15753 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 16128 15754 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 15755 + "dev": true, 16129 15756 "license": "MIT" 16130 15757 }, 16131 15758 "node_modules/path-exists": { ··· 16408 16035 "version": "3.2.5", 16409 16036 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", 16410 16037 "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", 16038 + "dev": true, 16411 16039 "bin": { 16412 16040 "prettier": "bin/prettier.cjs" 16413 16041 }, ··· 18504 18132 "version": "0.2.15", 18505 18133 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 18506 18134 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 18135 + "dev": true, 18507 18136 "license": "MIT", 18508 18137 "dependencies": { 18509 18138 "fdir": "^6.5.0", ··· 18520 18149 "version": "6.5.0", 18521 18150 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 18522 18151 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 18152 + "dev": true, 18523 18153 "license": "MIT", 18524 18154 "engines": { 18525 18155 "node": ">=12.0.0" ··· 18537 18167 "version": "4.0.3", 18538 18168 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 18539 18169 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 18170 + "dev": true, 18540 18171 "license": "MIT", 18541 18172 "engines": { 18542 18173 "node": ">=12"
+1 -1
package.json
··· 27 27 "@atproto/oauth-client-node": "^0.3.8", 28 28 "@atproto/sync": "^0.1.34", 29 29 "@atproto/syntax": "^0.3.3", 30 - "@atproto/tap": "^0.1.1", 31 30 "@atproto/xrpc": "^0.7.5", 32 31 "@atproto/xrpc-server": "^0.9.5", 33 32 "@hono/node-server": "^1.14.3", ··· 49 48 "@vercel/analytics": "^1.5.0", 50 49 "@vercel/functions": "^2.2.12", 51 50 "@vercel/sdk": "^1.11.4", 51 + "@yornaath/batshit": "^0.14.0", 52 52 "babel-plugin-react-compiler": "^19.1.0-rc.1", 53 53 "base64-js": "^1.5.1", 54 54 "colorjs.io": "^0.5.2",
+13
src/replicache/mutations.ts
··· 659 659 tags?: string[]; 660 660 cover_image?: string | null; 661 661 localPublishedAt?: string | null; 662 + preferences?: { 663 + showComments?: boolean; 664 + showMentions?: boolean; 665 + showRecommends?: boolean; 666 + } | null; 662 667 }> = async (args, ctx) => { 663 668 await ctx.runOnServer(async (serverCtx) => { 664 669 const updates: { ··· 666 671 title?: string; 667 672 tags?: string[]; 668 673 cover_image?: string | null; 674 + preferences?: { 675 + showComments?: boolean; 676 + showMentions?: boolean; 677 + showRecommends?: boolean; 678 + } | null; 669 679 } = {}; 670 680 if (args.description !== undefined) updates.description = args.description; 671 681 if (args.title !== undefined) updates.title = args.title; 672 682 if (args.tags !== undefined) updates.tags = args.tags; 673 683 if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 684 + if (args.preferences !== undefined) updates.preferences = args.preferences; 674 685 675 686 if (Object.keys(updates).length > 0) { 676 687 // First try to update leaflets_in_publications (for publications) ··· 699 710 await tx.set("publication_cover_image", args.cover_image); 700 711 if (args.localPublishedAt !== undefined) 701 712 await tx.set("publication_local_published_at", args.localPublishedAt); 713 + if (args.preferences !== undefined) 714 + await tx.set("post_preferences", args.preferences); 702 715 }); 703 716 }; 704 717
+24
src/utils/mergePreferences.ts
··· 1 + type PreferencesInput = { 2 + showComments?: boolean; 3 + showMentions?: boolean; 4 + showRecommends?: boolean; 5 + showPrevNext?: boolean; 6 + } | null; 7 + 8 + export function mergePreferences( 9 + documentPrefs?: PreferencesInput, 10 + publicationPrefs?: PreferencesInput, 11 + ): { 12 + showComments?: boolean; 13 + showMentions?: boolean; 14 + showRecommends?: boolean; 15 + showPrevNext?: boolean; 16 + } { 17 + return { 18 + showComments: documentPrefs?.showComments ?? publicationPrefs?.showComments, 19 + showMentions: documentPrefs?.showMentions ?? publicationPrefs?.showMentions, 20 + showRecommends: 21 + documentPrefs?.showRecommends ?? publicationPrefs?.showRecommends, 22 + showPrevNext: publicationPrefs?.showPrevNext, 23 + }; 24 + }
+6
supabase/database.types.ts
··· 589 589 description: string 590 590 doc: string | null 591 591 leaflet: string 592 + preferences: Json | null 592 593 publication: string 593 594 tags: string[] | null 594 595 title: string ··· 599 600 description?: string 600 601 doc?: string | null 601 602 leaflet: string 603 + preferences?: Json | null 602 604 publication: string 603 605 tags?: string[] | null 604 606 title?: string ··· 609 611 description?: string 610 612 doc?: string | null 611 613 leaflet?: string 614 + preferences?: Json | null 612 615 publication?: string 613 616 tags?: string[] | null 614 617 title?: string ··· 645 648 description: string 646 649 document: string 647 650 leaflet: string 651 + preferences: Json | null 648 652 tags: string[] | null 649 653 title: string 650 654 } ··· 655 659 description?: string 656 660 document: string 657 661 leaflet: string 662 + preferences?: Json | null 658 663 tags?: string[] | null 659 664 title?: string 660 665 } ··· 665 670 description?: string 666 671 document?: string 667 672 leaflet?: string 673 + preferences?: Json | null 668 674 tags?: string[] | null 669 675 title?: string 670 676 }
+2
supabase/migrations/20260208000000_add_preferences_to_drafts.sql
··· 1 + ALTER TABLE leaflets_in_publications ADD COLUMN preferences jsonb; 2 + ALTER TABLE leaflets_to_documents ADD COLUMN preferences jsonb;