a tool for shared writing and social publishing

add user preference to show/hide recommends

+363 -206
+2 -1
.claude/settings.local.json
··· 8 "mcp__primitive__pending_delegations", 9 "mcp__primitive__claim_delegation", 10 "mcp__primitive__tasks_update", 11 - "mcp__primitive__contexts_update" 12 ] 13 } 14 }
··· 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 }
+2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 206 preferences: { 207 showComments?: boolean; 208 showMentions?: boolean; 209 showPrevNext?: boolean; 210 }; 211 quotesCount: number | undefined; ··· 221 recommendsCount={props.recommendsCount} 222 showComments={props.preferences.showComments !== false} 223 showMentions={props.preferences.showMentions !== false} 224 pageId={props.pageId} 225 /> 226 {!props.isSubpage && (
··· 206 preferences: { 207 showComments?: boolean; 208 showMentions?: boolean; 209 + showRecommends?: boolean; 210 showPrevNext?: boolean; 211 }; 212 quotesCount: number | undefined; ··· 222 recommendsCount={props.recommendsCount} 223 showComments={props.preferences.showComments !== false} 224 showMentions={props.preferences.showMentions !== false} 225 + showRecommends={props.preferences.showRecommends !== false} 226 pageId={props.pageId} 227 /> 228 {!props.isSubpage && (
+19 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 112 className?: string; 113 showComments: boolean; 114 showMentions: boolean; 115 pageId?: string; 116 }) => { 117 const { ··· 132 const tags = normalizedDocument.tags; 133 const tagCount = tags?.length || 0; 134 135 - let interactionsAvailable = props.showComments || props.showMentions; 136 137 return ( 138 <div ··· 169 <QuoteTiny aria-hidden /> {props.quotesCount} 170 </button> 171 )} 172 - <RecommendButton 173 - documentUri={document_uri} 174 - recommendsCount={props.recommendsCount} 175 - /> 176 <Separator classname="h-4!" /> 177 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 178 </div> ··· 186 className?: string; 187 showComments: boolean; 188 showMentions: boolean; 189 pageId?: string; 190 }) => { 191 const { ··· 208 const tags = normalizedDocument.tags; 209 const tagCount = tags?.length || 0; 210 211 - let noInteractions = !props.showComments && !props.showMentions; 212 213 let subscribed = 214 identity?.atp_did && ··· 237 ) : ( 238 <> 239 <div className="flex gap-2 sm:flex-row flex-col"> 240 - <RecommendButton 241 - documentUri={document_uri} 242 - recommendsCount={props.recommendsCount} 243 - expanded 244 - /> 245 {props.quotesCount === 0 || !props.showMentions ? null : ( 246 <ButtonSecondary 247 onClick={() => {
··· 112 className?: string; 113 showComments: boolean; 114 showMentions: boolean; 115 + showRecommends: boolean; 116 pageId?: string; 117 }) => { 118 const { ··· 133 const tags = normalizedDocument.tags; 134 const tagCount = tags?.length || 0; 135 136 + let interactionsAvailable = 137 + props.showComments || props.showMentions || props.showRecommends; 138 139 return ( 140 <div ··· 171 <QuoteTiny aria-hidden /> {props.quotesCount} 172 </button> 173 )} 174 + {props.showRecommends === false ? null : ( 175 + <RecommendButton 176 + documentUri={document_uri} 177 + recommendsCount={props.recommendsCount} 178 + /> 179 + )} 180 <Separator classname="h-4!" /> 181 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 182 </div> ··· 190 className?: string; 191 showComments: boolean; 192 showMentions: boolean; 193 + showRecommends: boolean; 194 pageId?: string; 195 }) => { 196 const { ··· 213 const tags = normalizedDocument.tags; 214 const tagCount = tags?.length || 0; 215 216 + let noInteractions = 217 + !props.showComments && !props.showMentions && !props.showRecommends; 218 219 let subscribed = 220 identity?.atp_did && ··· 243 ) : ( 244 <> 245 <div className="flex gap-2 sm:flex-row flex-col"> 246 + {props.showRecommends === false ? null : ( 247 + <RecommendButton 248 + documentUri={document_uri} 249 + recommendsCount={props.recommendsCount} 250 + expanded 251 + /> 252 + )} 253 {props.quotesCount === 0 || !props.showMentions ? null : ( 254 <ButtonSecondary 255 onClick={() => {
+1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 87 pageId={pageId} 88 showComments={preferences.showComments !== false} 89 showMentions={preferences.showMentions !== false} 90 commentsCount={ 91 getCommentCount(document.comments_on_documents, pageId) || 0 92 }
··· 87 pageId={pageId} 88 showComments={preferences.showComments !== false} 89 showMentions={preferences.showMentions !== false} 90 + showRecommends={preferences.showRecommends !== false} 91 commentsCount={ 92 getCommentCount(document.comments_on_documents, pageId) || 0 93 }
+6 -1
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 export function PostHeader(props: { 19 data: PostPageData; 20 profile: ProfileViewDetailed; 21 - preferences: { showComments?: boolean; showMentions?: boolean }; 22 }) { 23 let { identity } = useIdentityData(); 24 let document = props.data; ··· 87 <Interactions 88 showComments={props.preferences.showComments !== false} 89 showMentions={props.preferences.showMentions !== false} 90 quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 commentsCount={ 92 getCommentCount(document?.comments_on_documents || []) || 0
··· 18 export function PostHeader(props: { 19 data: PostPageData; 20 profile: ProfileViewDetailed; 21 + preferences: { 22 + showComments?: boolean; 23 + showMentions?: boolean; 24 + showRecommends?: boolean; 25 + }; 26 }) { 27 let { identity } = useIdentityData(); 28 let document = props.data; ··· 91 <Interactions 92 showComments={props.preferences.showComments !== false} 93 showMentions={props.preferences.showMentions !== false} 94 + showRecommends={props.preferences.showRecommends !== false} 95 quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 96 commentsCount={ 97 getCommentCount(document?.comments_on_documents || []) || 0
+2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 170 preferences: { 171 showComments?: boolean; 172 showMentions?: boolean; 173 showPrevNext?: boolean; 174 }; 175 pubRecord?: NormalizedPublication | null; ··· 233 preferences: { 234 showComments?: boolean; 235 showMentions?: boolean; 236 showPrevNext?: boolean; 237 }; 238 pollData: PollData[];
··· 170 preferences: { 171 showComments?: boolean; 172 showMentions?: boolean; 173 + showRecommends?: boolean; 174 showPrevNext?: boolean; 175 }; 176 pubRecord?: NormalizedPublication | null; ··· 234 preferences: { 235 showComments?: boolean; 236 showMentions?: boolean; 237 + showRecommends?: boolean; 238 showPrevNext?: boolean; 239 }; 240 pollData: PollData[];
+1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 142 tags={doc.record.tags || []} 143 showComments={pubRecord?.preferences?.showComments !== false} 144 showMentions={pubRecord?.preferences?.showMentions !== false} 145 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 146 /> 147 </div>
··· 142 tags={doc.record.tags || []} 143 showComments={pubRecord?.preferences?.showComments !== false} 144 showMentions={pubRecord?.preferences?.showMentions !== false} 145 + showRecommends={pubRecord?.preferences?.showRecommends !== false} 146 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 147 /> 148 </div>
+21 -1
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 29 ? true 30 : record.preferences.showMentions, 31 ); 32 let [showPrevNext, setShowPrevNext] = useState( 33 record?.preferences?.showPrevNext === undefined 34 ? true ··· 53 showComments: showComments, 54 showMentions: showMentions, 55 showPrevNext: showPrevNext, 56 }, 57 }); 58 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 99 <div className="flex flex-col justify-start"> 100 <div className="font-bold">Show Mentions</div> 101 <div className="text-tertiary text-sm leading-tight"> 102 - Display a list of posts on Bluesky that mention your post 103 </div> 104 </div> 105 </Toggle>
··· 29 ? true 30 : record.preferences.showMentions, 31 ); 32 + let [showRecommends, setShowRecommends] = useState( 33 + record?.preferences?.showRecommends === undefined 34 + ? true 35 + : record.preferences.showRecommends, 36 + ); 37 let [showPrevNext, setShowPrevNext] = useState( 38 record?.preferences?.showPrevNext === undefined 39 ? true ··· 58 showComments: showComments, 59 showMentions: showMentions, 60 showPrevNext: showPrevNext, 61 + showRecommends: showRecommends, 62 }, 63 }); 64 toast({ type: "success", content: <strong>Posts Updated!</strong> }); ··· 105 <div className="flex flex-col justify-start"> 106 <div className="font-bold">Show Mentions</div> 107 <div className="text-tertiary text-sm leading-tight"> 108 + Display a list Bluesky mentions about your post 109 + </div> 110 + </div> 111 + </Toggle> 112 + 113 + <Toggle 114 + toggle={showRecommends} 115 + onToggle={() => { 116 + setShowRecommends(!showRecommends); 117 + }} 118 + > 119 + <div className="flex flex-col justify-start"> 120 + <div className="font-bold">Show Recommends</div> 121 + <div className="text-tertiary text-sm leading-tight"> 122 + Allow readers to recommend/like your post 123 </div> 124 </div> 125 </Toggle>
+3
app/lish/[did]/[publication]/page.tsx
··· 175 showMentions={ 176 record?.preferences?.showMentions !== false 177 } 178 /> 179 </div> 180 </div>
··· 175 showMentions={ 176 record?.preferences?.showMentions !== false 177 } 178 + showRecommends={ 179 + record?.preferences?.showRecommends !== false 180 + } 181 /> 182 </div> 183 </div>
+1
app/lish/createPub/CreatePubForm.tsx
··· 58 showComments: true, 59 showMentions: true, 60 showPrevNext: true, 61 }, 62 }); 63
··· 58 showComments: true, 59 showMentions: true, 60 showPrevNext: true, 61 + showRecommends: true, 62 }, 63 }); 64
+1 -2
app/lish/createPub/UpdatePubForm.tsx
··· 88 showComments: showComments, 89 showMentions: showMentions, 90 showPrevNext: showPrevNext, 91 }, 92 }); 93 toast({ type: "success", content: "Updated!" }); ··· 194 </p> 195 </div> 196 </Toggle> 197 - 198 - 199 </div> 200 </form> 201 );
··· 88 showComments: showComments, 89 showMentions: showMentions, 90 showPrevNext: showPrevNext, 91 + showRecommends: record?.preferences?.showRecommends ?? true, 92 }, 93 }); 94 toast({ type: "success", content: "Updated!" }); ··· 195 </p> 196 </div> 197 </Toggle> 198 </div> 199 </form> 200 );
+23 -9
app/lish/createPub/createPublication.ts
··· 5 PubLeafletPublication, 6 SiteStandardPublication, 7 } from "lexicons/api"; 8 - import { 9 - restoreOAuthSession, 10 - OAuthSessionError, 11 - } from "src/atproto-oauth"; 12 import { getIdentityData } from "actions/getIdentityData"; 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { Json } from "supabase/database.types"; ··· 76 77 // Build record based on publication type 78 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 - let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 80 81 // Upload the icon if provided 82 if (iconFile && iconFile.size > 0) { ··· 97 ...(iconBlob && { icon: iconBlob }), 98 basicTheme: { 99 $type: "site.standard.theme.basic", 100 - background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, 101 - foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, 102 - accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, 103 - accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, 104 }, 105 preferences: { 106 showInDiscover: preferences.showInDiscover, 107 showComments: preferences.showComments, 108 showMentions: preferences.showMentions, 109 showPrevNext: preferences.showPrevNext, 110 }, 111 } satisfies SiteStandardPublication.Record; 112 } else {
··· 5 PubLeafletPublication, 6 SiteStandardPublication, 7 } from "lexicons/api"; 8 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; ··· 73 74 // Build record based on publication type 75 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 76 + let iconBlob: 77 + | Awaited< 78 + ReturnType<typeof agent.com.atproto.repo.uploadBlob> 79 + >["data"]["blob"] 80 + | undefined; 81 82 // Upload the icon if provided 83 if (iconFile && iconFile.size > 0) { ··· 98 ...(iconBlob && { icon: iconBlob }), 99 basicTheme: { 100 $type: "site.standard.theme.basic", 101 + background: { 102 + $type: "site.standard.theme.color#rgb", 103 + ...PubThemeDefaultsRGB.background, 104 + }, 105 + foreground: { 106 + $type: "site.standard.theme.color#rgb", 107 + ...PubThemeDefaultsRGB.foreground, 108 + }, 109 + accent: { 110 + $type: "site.standard.theme.color#rgb", 111 + ...PubThemeDefaultsRGB.accent, 112 + }, 113 + accentForeground: { 114 + $type: "site.standard.theme.color#rgb", 115 + ...PubThemeDefaultsRGB.accentForeground, 116 + }, 117 }, 118 preferences: { 119 showInDiscover: preferences.showInDiscover, 120 showComments: preferences.showComments, 121 showMentions: preferences.showMentions, 122 showPrevNext: preferences.showPrevNext, 123 + showRecommends: preferences.showRecommends, 124 }, 125 } satisfies SiteStandardPublication.Record; 126 } else {
+167 -98
app/lish/createPub/updatePublication.ts
··· 77 } 78 79 const aturi = new AtUri(existingPub.uri); 80 - const publicationType = getPublicationType(aturi.collection) as PublicationType; 81 82 // Normalize existing record 83 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 128 } 129 130 /** Merges override with existing value, respecting explicit undefined */ 131 - function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 132 return hasOverride ? override : existing; 133 } 134 ··· 146 return { 147 $type: "pub.leaflet.publication", 148 name: overrides.name ?? normalizedPub?.name ?? "", 149 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 152 base_path: overrides.basePath ?? existingBasePath, 153 - preferences: preferences ? { 154 - $type: "pub.leaflet.publication#preferences", 155 - showInDiscover: preferences.showInDiscover, 156 - showComments: preferences.showComments, 157 - showMentions: preferences.showMentions, 158 - showPrevNext: preferences.showPrevNext, 159 - } : undefined, 160 }; 161 } 162 ··· 175 return { 176 $type: "site.standard.publication", 177 name: overrides.name ?? normalizedPub?.name ?? "", 178 - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 - basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 182 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 - preferences: preferences ? { 184 - showInDiscover: preferences.showInDiscover, 185 - showComments: preferences.showComments, 186 - showMentions: preferences.showMentions, 187 - showPrevNext: preferences.showPrevNext, 188 - } : undefined, 189 }; 190 } 191 ··· 217 iconFile?: File | null; 218 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 }): Promise<UpdatePublicationResult> { 220 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 - // Upload icon if provided 222 - let iconBlob = normalizedPub?.icon; 223 - if (iconFile && iconFile.size > 0) { 224 - const buffer = await iconFile.arrayBuffer(); 225 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 - new Uint8Array(buffer), 227 - { encoding: iconFile.type }, 228 - ); 229 - if (uploadResult.data.blob) { 230 - iconBlob = uploadResult.data.blob; 231 } 232 - } 233 234 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 - name, 236 - description, 237 - icon: iconBlob, 238 - preferences, 239 - }); 240 - }); 241 } 242 243 export async function updatePublicationBasePath({ ··· 247 uri: string; 248 base_path: string; 249 }): Promise<UpdatePublicationResult> { 250 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 - basePath: base_path, 253 - }); 254 - }); 255 } 256 257 type Color = ··· 275 accentText: Color; 276 }; 277 }): Promise<UpdatePublicationResult> { 278 - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 - // Build theme object 280 - const themeData = { 281 - $type: "pub.leaflet.publication#theme" as const, 282 - backgroundImage: theme.backgroundImage 283 - ? { 284 - $type: "pub.leaflet.theme.backgroundImage", 285 - image: ( 286 - await agent.com.atproto.repo.uploadBlob( 287 - new Uint8Array(await theme.backgroundImage.arrayBuffer()), 288 - { encoding: theme.backgroundImage.type }, 289 - ) 290 - )?.data.blob, 291 - width: theme.backgroundRepeat || undefined, 292 - repeat: !!theme.backgroundRepeat, 293 - } 294 - : theme.backgroundImage === null 295 - ? undefined 296 - : normalizedPub?.theme?.backgroundImage, 297 - backgroundColor: theme.backgroundColor 298 - ? { 299 - ...theme.backgroundColor, 300 - } 301 - : undefined, 302 - pageWidth: theme.pageWidth, 303 - primary: { 304 - ...theme.primary, 305 - }, 306 - pageBackground: { 307 - ...theme.pageBackground, 308 - }, 309 - showPageBackground: theme.showPageBackground, 310 - accentBackground: { 311 - ...theme.accentBackground, 312 - }, 313 - accentText: { 314 - ...theme.accentText, 315 - }, 316 - }; 317 318 - // Derive basicTheme from the theme colors for site.standard.publication 319 - const basicTheme: NormalizedPublication["basicTheme"] = { 320 - $type: "site.standard.theme.basic", 321 - background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 322 - foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 323 - accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 324 - accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 325 - }; 326 327 - return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 - theme: themeData, 329 - basicTheme, 330 - }); 331 - }); 332 }
··· 77 } 78 79 const aturi = new AtUri(existingPub.uri); 80 + const publicationType = getPublicationType( 81 + aturi.collection, 82 + ) as PublicationType; 83 84 // Normalize existing record 85 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 130 } 131 132 /** Merges override with existing value, respecting explicit undefined */ 133 + function resolveField<T>( 134 + override: T | undefined, 135 + existing: T | undefined, 136 + hasOverride: boolean, 137 + ): T | undefined { 138 return hasOverride ? override : existing; 139 } 140 ··· 152 return { 153 $type: "pub.leaflet.publication", 154 name: overrides.name ?? normalizedPub?.name ?? "", 155 + description: resolveField( 156 + overrides.description, 157 + normalizedPub?.description, 158 + "description" in overrides, 159 + ), 160 + icon: resolveField( 161 + overrides.icon, 162 + normalizedPub?.icon, 163 + "icon" in overrides, 164 + ), 165 + theme: resolveField( 166 + overrides.theme, 167 + normalizedPub?.theme, 168 + "theme" in overrides, 169 + ), 170 base_path: overrides.basePath ?? existingBasePath, 171 + preferences: preferences 172 + ? { 173 + $type: "pub.leaflet.publication#preferences", 174 + showInDiscover: preferences.showInDiscover, 175 + showComments: preferences.showComments, 176 + showMentions: preferences.showMentions, 177 + showPrevNext: preferences.showPrevNext, 178 + showRecommends: preferences.showRecommends, 179 + } 180 + : undefined, 181 }; 182 } 183 ··· 196 return { 197 $type: "site.standard.publication", 198 name: overrides.name ?? normalizedPub?.name ?? "", 199 + description: resolveField( 200 + overrides.description, 201 + normalizedPub?.description, 202 + "description" in overrides, 203 + ), 204 + icon: resolveField( 205 + overrides.icon, 206 + normalizedPub?.icon, 207 + "icon" in overrides, 208 + ), 209 + theme: resolveField( 210 + overrides.theme, 211 + normalizedPub?.theme, 212 + "theme" in overrides, 213 + ), 214 + basicTheme: resolveField( 215 + overrides.basicTheme, 216 + normalizedPub?.basicTheme, 217 + "basicTheme" in overrides, 218 + ), 219 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 220 + preferences: preferences 221 + ? { 222 + showInDiscover: preferences.showInDiscover, 223 + showComments: preferences.showComments, 224 + showMentions: preferences.showMentions, 225 + showPrevNext: preferences.showPrevNext, 226 + showRecommends: preferences.showRecommends, 227 + } 228 + : undefined, 229 }; 230 } 231 ··· 257 iconFile?: File | null; 258 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 259 }): Promise<UpdatePublicationResult> { 260 + return withPublicationUpdate( 261 + uri, 262 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 263 + // Upload icon if provided 264 + let iconBlob = normalizedPub?.icon; 265 + if (iconFile && iconFile.size > 0) { 266 + const buffer = await iconFile.arrayBuffer(); 267 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 268 + new Uint8Array(buffer), 269 + { encoding: iconFile.type }, 270 + ); 271 + if (uploadResult.data.blob) { 272 + iconBlob = uploadResult.data.blob; 273 + } 274 } 275 276 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 277 + name, 278 + description, 279 + icon: iconBlob, 280 + preferences, 281 + }); 282 + }, 283 + ); 284 } 285 286 export async function updatePublicationBasePath({ ··· 290 uri: string; 291 base_path: string; 292 }): Promise<UpdatePublicationResult> { 293 + return withPublicationUpdate( 294 + uri, 295 + async ({ normalizedPub, existingBasePath, publicationType }) => { 296 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 297 + basePath: base_path, 298 + }); 299 + }, 300 + ); 301 } 302 303 type Color = ··· 321 accentText: Color; 322 }; 323 }): Promise<UpdatePublicationResult> { 324 + return withPublicationUpdate( 325 + uri, 326 + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 327 + // Build theme object 328 + const themeData = { 329 + $type: "pub.leaflet.publication#theme" as const, 330 + backgroundImage: theme.backgroundImage 331 + ? { 332 + $type: "pub.leaflet.theme.backgroundImage", 333 + image: ( 334 + await agent.com.atproto.repo.uploadBlob( 335 + new Uint8Array(await theme.backgroundImage.arrayBuffer()), 336 + { encoding: theme.backgroundImage.type }, 337 + ) 338 + )?.data.blob, 339 + width: theme.backgroundRepeat || undefined, 340 + repeat: !!theme.backgroundRepeat, 341 + } 342 + : theme.backgroundImage === null 343 + ? undefined 344 + : normalizedPub?.theme?.backgroundImage, 345 + backgroundColor: theme.backgroundColor 346 + ? { 347 + ...theme.backgroundColor, 348 + } 349 + : undefined, 350 + pageWidth: theme.pageWidth, 351 + primary: { 352 + ...theme.primary, 353 + }, 354 + pageBackground: { 355 + ...theme.pageBackground, 356 + }, 357 + showPageBackground: theme.showPageBackground, 358 + accentBackground: { 359 + ...theme.accentBackground, 360 + }, 361 + accentText: { 362 + ...theme.accentText, 363 + }, 364 + }; 365 366 + // Derive basicTheme from the theme colors for site.standard.publication 367 + const basicTheme: NormalizedPublication["basicTheme"] = { 368 + $type: "site.standard.theme.basic", 369 + background: { 370 + $type: "site.standard.theme.color#rgb", 371 + r: theme.backgroundColor.r, 372 + g: theme.backgroundColor.g, 373 + b: theme.backgroundColor.b, 374 + }, 375 + foreground: { 376 + $type: "site.standard.theme.color#rgb", 377 + r: theme.primary.r, 378 + g: theme.primary.g, 379 + b: theme.primary.b, 380 + }, 381 + accent: { 382 + $type: "site.standard.theme.color#rgb", 383 + r: theme.accentBackground.r, 384 + g: theme.accentBackground.g, 385 + b: theme.accentBackground.b, 386 + }, 387 + accentForeground: { 388 + $type: "site.standard.theme.color#rgb", 389 + r: theme.accentText.r, 390 + g: theme.accentText.g, 391 + b: theme.accentText.b, 392 + }, 393 + }; 394 395 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 396 + theme: themeData, 397 + basicTheme, 398 + }); 399 + }, 400 + ); 401 }
+9 -5
components/InteractionsPreview.tsx
··· 18 postUrl: string; 19 showComments: boolean; 20 showMentions: boolean; 21 22 share?: boolean; 23 }) => { 24 let smoker = useSmoker(); 25 let interactionsAvailable = 26 (props.quotesCount > 0 && props.showMentions) || 27 - (props.showComments !== false && props.commentsCount > 0); 28 29 const tagsCount = props.tags?.length || 0; 30 31 return ( 32 <div className={`flex gap-2 text-tertiary text-sm items-center`}> 33 - <RecommendButton 34 - documentUri={props.documentUri} 35 - recommendsCount={props.recommendsCount} 36 - /> 37 38 {!props.showMentions || props.quotesCount === 0 ? null : ( 39 <SpeedyLink
··· 18 postUrl: string; 19 showComments: boolean; 20 showMentions: boolean; 21 + showRecommends: boolean; 22 23 share?: boolean; 24 }) => { 25 let smoker = useSmoker(); 26 let interactionsAvailable = 27 (props.quotesCount > 0 && props.showMentions) || 28 + (props.showComments !== false && props.commentsCount > 0) || 29 + (props.showRecommends !== false && props.recommendsCount > 0); 30 31 const tagsCount = props.tags?.length || 0; 32 33 return ( 34 <div className={`flex gap-2 text-tertiary text-sm items-center`}> 35 + {props.showRecommends === false ? null : ( 36 + <RecommendButton 37 + documentUri={props.documentUri} 38 + recommendsCount={props.recommendsCount} 39 + /> 40 + )} 41 42 {!props.showMentions || props.quotesCount === 0 ? null : ( 43 <SpeedyLink
+3
components/PostListing.tsx
··· 111 tags={tags} 112 showComments={pubRecord?.preferences?.showComments !== false} 113 showMentions={pubRecord?.preferences?.showMentions !== false} 114 share 115 /> 116 </div>
··· 111 tags={tags} 112 showComments={pubRecord?.preferences?.showComments !== false} 113 showMentions={pubRecord?.preferences?.showMentions !== false} 114 + showRecommends={ 115 + pubRecord?.preferences?.showRecommends !== false 116 + } 117 share 118 /> 119 </div>
+44 -39
lexicons/api/types/pub/leaflet/publication.ts
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../lexicons' 7 - import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - import type * as PubLeafletThemeColor from './theme/color' 9 - import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage' 10 11 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'pub.leaflet.publication' 14 15 export interface Record { 16 - $type: 'pub.leaflet.publication' 17 - name: string 18 - base_path?: string 19 - description?: string 20 - icon?: BlobRef 21 - theme?: Theme 22 - preferences?: Preferences 23 - [k: string]: unknown 24 } 25 26 - const hashRecord = 'main' 27 28 export function isRecord<V>(v: V) { 29 - return is$typed(v, id, hashRecord) 30 } 31 32 export function validateRecord<V>(v: V) { 33 - return validate<Record & V>(v, id, hashRecord, true) 34 } 35 36 export interface Preferences { 37 - $type?: 'pub.leaflet.publication#preferences' 38 - showInDiscover: boolean 39 - showComments: boolean 40 - showMentions: boolean 41 - showPrevNext: boolean 42 } 43 44 - const hashPreferences = 'preferences' 45 46 export function isPreferences<V>(v: V) { 47 - return is$typed(v, id, hashPreferences) 48 } 49 50 export function validatePreferences<V>(v: V) { 51 - return validate<Preferences & V>(v, id, hashPreferences) 52 } 53 54 export interface Theme { 55 - $type?: 'pub.leaflet.publication#theme' 56 backgroundColor?: 57 | $Typed<PubLeafletThemeColor.Rgba> 58 | $Typed<PubLeafletThemeColor.Rgb> 59 - | { $type: string } 60 - backgroundImage?: PubLeafletThemeBackgroundImage.Main 61 - pageWidth?: number 62 primary?: 63 | $Typed<PubLeafletThemeColor.Rgba> 64 | $Typed<PubLeafletThemeColor.Rgb> 65 - | { $type: string } 66 pageBackground?: 67 | $Typed<PubLeafletThemeColor.Rgba> 68 | $Typed<PubLeafletThemeColor.Rgb> 69 - | { $type: string } 70 - showPageBackground: boolean 71 accentBackground?: 72 | $Typed<PubLeafletThemeColor.Rgba> 73 | $Typed<PubLeafletThemeColor.Rgb> 74 - | { $type: string } 75 accentText?: 76 | $Typed<PubLeafletThemeColor.Rgba> 77 | $Typed<PubLeafletThemeColor.Rgb> 78 - | { $type: string } 79 } 80 81 - const hashTheme = 'theme' 82 83 export function isTheme<V>(v: V) { 84 - return is$typed(v, id, hashTheme) 85 } 86 87 export function validateTheme<V>(v: V) { 88 - return validate<Theme & V>(v, id, hashTheme) 89 }
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 + import { type ValidationResult, BlobRef } from "@atproto/lexicon"; 5 + import { CID } from "multiformats/cid"; 6 + import { validate as _validate } from "../../../lexicons"; 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from "../../../util"; 12 + import type * as PubLeafletThemeColor from "./theme/color"; 13 + import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage"; 14 15 const is$typed = _is$typed, 16 + validate = _validate; 17 + const id = "pub.leaflet.publication"; 18 19 export interface Record { 20 + $type: "pub.leaflet.publication"; 21 + name: string; 22 + base_path?: string; 23 + description?: string; 24 + icon?: BlobRef; 25 + theme?: Theme; 26 + preferences?: Preferences; 27 + [k: string]: unknown; 28 } 29 30 + const hashRecord = "main"; 31 32 export function isRecord<V>(v: V) { 33 + return is$typed(v, id, hashRecord); 34 } 35 36 export function validateRecord<V>(v: V) { 37 + return validate<Record & V>(v, id, hashRecord, true); 38 } 39 40 export interface Preferences { 41 + $type?: "pub.leaflet.publication#preferences"; 42 + showInDiscover: boolean; 43 + showComments: boolean; 44 + showMentions: boolean; 45 + showPrevNext: boolean; 46 + showRecommends: boolean; 47 } 48 49 + const hashPreferences = "preferences"; 50 51 export function isPreferences<V>(v: V) { 52 + return is$typed(v, id, hashPreferences); 53 } 54 55 export function validatePreferences<V>(v: V) { 56 + return validate<Preferences & V>(v, id, hashPreferences); 57 } 58 59 export interface Theme { 60 + $type?: "pub.leaflet.publication#theme"; 61 backgroundColor?: 62 | $Typed<PubLeafletThemeColor.Rgba> 63 | $Typed<PubLeafletThemeColor.Rgb> 64 + | { $type: string }; 65 + backgroundImage?: PubLeafletThemeBackgroundImage.Main; 66 + pageWidth?: number; 67 primary?: 68 | $Typed<PubLeafletThemeColor.Rgba> 69 | $Typed<PubLeafletThemeColor.Rgb> 70 + | { $type: string }; 71 pageBackground?: 72 | $Typed<PubLeafletThemeColor.Rgba> 73 | $Typed<PubLeafletThemeColor.Rgb> 74 + | { $type: string }; 75 + showPageBackground: boolean; 76 accentBackground?: 77 | $Typed<PubLeafletThemeColor.Rgba> 78 | $Typed<PubLeafletThemeColor.Rgb> 79 + | { $type: string }; 80 accentText?: 81 | $Typed<PubLeafletThemeColor.Rgba> 82 | $Typed<PubLeafletThemeColor.Rgb> 83 + | { $type: string }; 84 } 85 86 + const hashTheme = "theme"; 87 88 export function isTheme<V>(v: V) { 89 + return is$typed(v, id, hashTheme); 90 } 91 92 export function validateTheme<V>(v: V) { 93 + return validate<Theme & V>(v, id, hashTheme); 94 }
+33 -28
lexicons/api/types/site/standard/publication.ts
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../lexicons' 7 - import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - import type * as SiteStandardThemeBasic from './theme/basic' 9 - import type * as PubLeafletPublication from '../../pub/leaflet/publication' 10 11 const is$typed = _is$typed, 12 - validate = _validate 13 - const id = 'site.standard.publication' 14 15 export interface Record { 16 - $type: 'site.standard.publication' 17 - basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 19 - description?: string 20 - icon?: BlobRef 21 - name: string 22 - preferences?: Preferences 23 - url: string 24 - [k: string]: unknown 25 } 26 27 - const hashRecord = 'main' 28 29 export function isRecord<V>(v: V) { 30 - return is$typed(v, id, hashRecord) 31 } 32 33 export function validateRecord<V>(v: V) { 34 - return validate<Record & V>(v, id, hashRecord, true) 35 } 36 37 export interface Preferences { 38 - $type?: 'site.standard.publication#preferences' 39 - showInDiscover: boolean 40 - showComments: boolean 41 - showMentions: boolean 42 - showPrevNext: boolean 43 } 44 45 - const hashPreferences = 'preferences' 46 47 export function isPreferences<V>(v: V) { 48 - return is$typed(v, id, hashPreferences) 49 } 50 51 export function validatePreferences<V>(v: V) { 52 - return validate<Preferences & V>(v, id, hashPreferences) 53 }
··· 1 /** 2 * GENERATED CODE - DO NOT MODIFY 3 */ 4 + import { type ValidationResult, BlobRef } from "@atproto/lexicon"; 5 + import { CID } from "multiformats/cid"; 6 + import { validate as _validate } from "../../../lexicons"; 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from "../../../util"; 12 + import type * as SiteStandardThemeBasic from "./theme/basic"; 13 + import type * as PubLeafletPublication from "../../pub/leaflet/publication"; 14 15 const is$typed = _is$typed, 16 + validate = _validate; 17 + const id = "site.standard.publication"; 18 19 export interface Record { 20 + $type: "site.standard.publication"; 21 + basicTheme?: SiteStandardThemeBasic.Main; 22 + theme?: $Typed<PubLeafletPublication.Theme> | { $type: string }; 23 + description?: string; 24 + icon?: BlobRef; 25 + name: string; 26 + preferences?: Preferences; 27 + url: string; 28 + [k: string]: unknown; 29 } 30 31 + const hashRecord = "main"; 32 33 export function isRecord<V>(v: V) { 34 + return is$typed(v, id, hashRecord); 35 } 36 37 export function validateRecord<V>(v: V) { 38 + return validate<Record & V>(v, id, hashRecord, true); 39 } 40 41 export interface Preferences { 42 + $type?: "site.standard.publication#preferences"; 43 + showInDiscover: boolean; 44 + showComments: boolean; 45 + showMentions: boolean; 46 + showPrevNext: boolean; 47 + showRecommends: boolean; 48 } 49 50 + const hashPreferences = "preferences"; 51 52 export function isPreferences<V>(v: V) { 53 + return is$typed(v, id, hashPreferences); 54 } 55 56 export function validatePreferences<V>(v: V) { 57 + return validate<Preferences & V>(v, id, hashPreferences); 58 }
+4
lexicons/pub/leaflet/publication.json
··· 59 "showPrevNext": { 60 "type": "boolean", 61 "default": true 62 } 63 } 64 },
··· 59 "showPrevNext": { 60 "type": "boolean", 61 "default": true 62 + }, 63 + "showRecommends": { 64 + "type": "boolean", 65 + "default": true 66 } 67 } 68 },
+4
lexicons/site/standard/publication.json
··· 58 "showPrevNext": { 59 "default": false, 60 "type": "boolean" 61 } 62 }, 63 "type": "object"
··· 58 "showPrevNext": { 59 "default": false, 60 "type": "boolean" 61 + }, 62 + "showRecommends": { 63 + "default": true, 64 + "type": "boolean" 65 } 66 }, 67 "type": "object"
+16 -11
lexicons/src/normalize.ts
··· 50 * Checks if the record is a pub.leaflet.document 51 */ 52 export function isLeafletDocument( 53 - record: unknown 54 ): record is PubLeafletDocument.Record { 55 if (!record || typeof record !== "object") return false; 56 const r = record as Record<string, unknown>; ··· 65 * Checks if the record is a site.standard.document 66 */ 67 export function isStandardDocument( 68 - record: unknown 69 ): record is SiteStandardDocument.Record { 70 if (!record || typeof record !== "object") return false; 71 const r = record as Record<string, unknown>; ··· 76 * Checks if the record is a pub.leaflet.publication 77 */ 78 export function isLeafletPublication( 79 - record: unknown 80 ): record is PubLeafletPublication.Record { 81 if (!record || typeof record !== "object") return false; 82 const r = record as Record<string, unknown>; ··· 91 * Checks if the record is a site.standard.publication 92 */ 93 export function isStandardPublication( 94 - record: unknown 95 ): record is SiteStandardPublication.Record { 96 if (!record || typeof record !== "object") return false; 97 const r = record as Record<string, unknown>; ··· 106 | $Typed<PubLeafletThemeColor.Rgba> 107 | $Typed<PubLeafletThemeColor.Rgb> 108 | { $type: string } 109 - | undefined 110 ): { r: number; g: number; b: number } | undefined { 111 if (!color || typeof color !== "object") return undefined; 112 const c = color as Record<string, unknown>; ··· 124 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 */ 126 export function leafletThemeToBasicTheme( 127 - theme: PubLeafletPublication.Theme | undefined 128 ): SiteStandardThemeBasic.Main | undefined { 129 if (!theme) return undefined; 130 131 const background = extractRgb(theme.backgroundColor); 132 - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 const accentForeground = extractRgb(theme.accentText); 134 135 // If we don't have the required colors, return undefined ··· 160 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 */ 163 - export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 164 if (!record || typeof record !== "object") return null; 165 166 // Pass through site.standard records directly (theme is already in correct format if present) ··· 219 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 */ 221 export function normalizePublication( 222 - record: unknown 223 ): NormalizedPublication | null { 224 if (!record || typeof record !== "object") return null; 225 ··· 268 showComments: record.preferences.showComments, 269 showMentions: record.preferences.showMentions, 270 showPrevNext: record.preferences.showPrevNext, 271 } 272 : undefined; 273 ··· 290 * Type guard to check if a normalized document has leaflet content 291 */ 292 export function hasLeafletContent( 293 - doc: NormalizedDocument 294 ): doc is NormalizedDocument & { 295 content: $Typed<PubLeafletContent.Main>; 296 } { ··· 304 * Gets the pages array from a normalized document, handling both formats 305 */ 306 export function getDocumentPages( 307 - doc: NormalizedDocument 308 ): PubLeafletContent.Main["pages"] | undefined { 309 if (!doc.content) return undefined; 310
··· 50 * Checks if the record is a pub.leaflet.document 51 */ 52 export function isLeafletDocument( 53 + record: unknown, 54 ): record is PubLeafletDocument.Record { 55 if (!record || typeof record !== "object") return false; 56 const r = record as Record<string, unknown>; ··· 65 * Checks if the record is a site.standard.document 66 */ 67 export function isStandardDocument( 68 + record: unknown, 69 ): record is SiteStandardDocument.Record { 70 if (!record || typeof record !== "object") return false; 71 const r = record as Record<string, unknown>; ··· 76 * Checks if the record is a pub.leaflet.publication 77 */ 78 export function isLeafletPublication( 79 + record: unknown, 80 ): record is PubLeafletPublication.Record { 81 if (!record || typeof record !== "object") return false; 82 const r = record as Record<string, unknown>; ··· 91 * Checks if the record is a site.standard.publication 92 */ 93 export function isStandardPublication( 94 + record: unknown, 95 ): record is SiteStandardPublication.Record { 96 if (!record || typeof record !== "object") return false; 97 const r = record as Record<string, unknown>; ··· 106 | $Typed<PubLeafletThemeColor.Rgba> 107 | $Typed<PubLeafletThemeColor.Rgb> 108 | { $type: string } 109 + | undefined, 110 ): { r: number; g: number; b: number } | undefined { 111 if (!color || typeof color !== "object") return undefined; 112 const c = color as Record<string, unknown>; ··· 124 * Converts a pub.leaflet theme to a site.standard.theme.basic format 125 */ 126 export function leafletThemeToBasicTheme( 127 + theme: PubLeafletPublication.Theme | undefined, 128 ): SiteStandardThemeBasic.Main | undefined { 129 if (!theme) return undefined; 130 131 const background = extractRgb(theme.backgroundColor); 132 + const accent = 133 + extractRgb(theme.accentBackground) || extractRgb(theme.primary); 134 const accentForeground = extractRgb(theme.accentText); 135 136 // If we don't have the required colors, return undefined ··· 161 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 162 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 163 */ 164 + export function normalizeDocument( 165 + record: unknown, 166 + uri?: string, 167 + ): NormalizedDocument | null { 168 if (!record || typeof record !== "object") return null; 169 170 // Pass through site.standard records directly (theme is already in correct format if present) ··· 223 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 224 */ 225 export function normalizePublication( 226 + record: unknown, 227 ): NormalizedPublication | null { 228 if (!record || typeof record !== "object") return null; 229 ··· 272 showComments: record.preferences.showComments, 273 showMentions: record.preferences.showMentions, 274 showPrevNext: record.preferences.showPrevNext, 275 + showRecommends: record.preferences.showRecommends, 276 } 277 : undefined; 278 ··· 295 * Type guard to check if a normalized document has leaflet content 296 */ 297 export function hasLeafletContent( 298 + doc: NormalizedDocument, 299 ): doc is NormalizedDocument & { 300 content: $Typed<PubLeafletContent.Main>; 301 } { ··· 309 * Gets the pages array from a normalized document, handling both formats 310 */ 311 export function getDocumentPages( 312 + doc: NormalizedDocument, 313 ): PubLeafletContent.Main["pages"] | undefined { 314 if (!doc.content) return undefined; 315
+1
lexicons/src/publication.ts
··· 29 showComments: { type: "boolean", default: true }, 30 showMentions: { type: "boolean", default: true }, 31 showPrevNext: { type: "boolean", default: true }, 32 }, 33 }, 34 theme: {
··· 29 showComments: { type: "boolean", default: true }, 30 showMentions: { type: "boolean", default: true }, 31 showPrevNext: { type: "boolean", default: true }, 32 + showRecommends: { type: "boolean", default: true }, 33 }, 34 }, 35 theme: {