diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..9371392c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "mcp__acp__Edit", + "mcp__acp__Write", + "mcp__acp__Bash", + "mcp__primitive__say_hello", + "mcp__primitive__pending_delegations", + "mcp__primitive__claim_delegation", + "mcp__primitive__tasks_update", + "mcp__primitive__contexts_update", + "mcp__primitive__contexts_list" + ] + } +} diff --git a/.claude/skills/lexicons.md b/.claude/skills/lexicons.md deleted file mode 100644 index bc14e81b..00000000 --- a/.claude/skills/lexicons.md +++ /dev/null @@ -1,298 +0,0 @@ -# Lexicon System - -## Overview - -Lexicons define the schema for AT Protocol records. This project has two namespaces: -- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) -- **`site.standard.*`** - Standard site lexicons for interoperability - -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/`. - -## Key Files - -- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons -- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) -- **`lexicons/build.ts`** - Builds TypeScript sources to JSON -- **`lexicons/api/`** - Generated TypeScript types and client -- **`package.json`** - Contains `lexgen` script - -## Running Lexicon Generation - -```bash -npm run lexgen -``` - -This runs: -1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript -2. `lex gen-api` - Generates TypeScript types from all JSON lexicons -3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions - -## Adding a New pub.leaflet Lexicon - -### 1. Create the Source Definition - -Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): - -```typescript -import { LexiconDoc } from "@atproto/lexicon"; - -export const PubLeafletMyLexicon: LexiconDoc = { - lexicon: 1, - id: "pub.leaflet.myLexicon", - defs: { - main: { - type: "record", // or "object" for non-record types - key: "tid", - record: { - type: "object", - required: ["field1"], - properties: { - field1: { type: "string", maxLength: 1000 }, - field2: { type: "integer", minimum: 0 }, - optionalRef: { type: "ref", ref: "other.lexicon#def" }, - }, - }, - }, - // Additional defs for sub-objects - subType: { - type: "object", - properties: { - nested: { type: "string" }, - }, - }, - }, -}; -``` - -### 2. Add to Build - -Update `lexicons/build.ts`: - -```typescript -import { PubLeafletMyLexicon } from "./src/myLexicon"; - -const lexicons = [ - // ... existing lexicons - PubLeafletMyLexicon, -]; -``` - -### 3. Update lexgen Command (if needed) - -If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: - -```json -"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." -``` - -Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. - -### 4. Add to authFullPermissions (for record types) - -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`: - -```typescript -import { PubLeafletMyLexicon } from "./myLexicon"; - -// In the permissions collection array: -collection: [ - // ... existing lexicons - PubLeafletMyLexicon.id, -], -``` - -### 5. Regenerate Types - -```bash -npm run lexgen -``` - -### 6. Use the Generated Types - -```typescript -import { PubLeafletMyLexicon } from "lexicons/api"; - -// Type for the record -type MyRecord = PubLeafletMyLexicon.Record; - -// Validation -const result = PubLeafletMyLexicon.validateRecord(data); -if (result.success) { - // result.value is typed -} - -// Type guard -if (PubLeafletMyLexicon.isRecord(data)) { - // data is typed as Record -} -``` - -## Adding a New site.standard Lexicon - -### 1. Create the JSON Definition - -Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): - -```json -{ - "lexicon": 1, - "id": "site.standard.myType", - "defs": { - "main": { - "type": "record", - "key": "tid", - "record": { - "type": "object", - "required": ["field1"], - "properties": { - "field1": { - "type": "string", - "maxLength": 1000 - } - } - } - } - } -} -``` - -### 2. Regenerate Types - -```bash -npm run lexgen -``` - -The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. - -## Common Lexicon Patterns - -### Referencing Other Lexicons - -```typescript -// Reference another lexicon's main def -{ type: "ref", ref: "pub.leaflet.publication" } - -// Reference a specific def within a lexicon -{ type: "ref", ref: "pub.leaflet.publication#theme" } - -// Reference within the same lexicon -{ type: "ref", ref: "#myDef" } -``` - -### Union Types - -```typescript -{ - type: "union", - refs: [ - "pub.leaflet.pages.linearDocument", - "pub.leaflet.pages.canvas", - ], -} - -// Open union (allows unknown types) -{ - type: "union", - closed: false, // default is true - refs: ["pub.leaflet.content"], -} -``` - -### Blob Types (for images/files) - -```typescript -{ - type: "blob", - accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] - maxSize: 1000000, // bytes -} -``` - -### Color Types - -The project has color types defined: -- `pub.leaflet.theme.color#rgb` / `#rgba` -- `site.standard.theme.color#rgb` / `#rgba` - -```typescript -// In lexicons/src/theme.ts -export const ColorUnion = { - type: "union", - refs: [ - "pub.leaflet.theme.color#rgba", - "pub.leaflet.theme.color#rgb", - ], -}; -``` - -## Normalization Between Formats - -Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: - -```typescript -import { - normalizeDocument, - normalizePublication, - isLeafletDocument, - isStandardDocument, - getDocumentPages, -} from "lexicons/src/normalize"; - -// Normalize a document from either format -const normalized = normalizeDocument(record); -if (normalized) { - // normalized is always in site.standard.document format - console.log(normalized.title, normalized.site); - - // Get pages if content is pub.leaflet.content - const pages = getDocumentPages(normalized); -} - -// Normalize a publication -const pub = normalizePublication(record); -if (pub) { - console.log(pub.name, pub.url); -} -``` - -## Handling in Appview (Firehose Consumer) - -When processing records from the firehose in `appview/index.ts`: - -```typescript -import { ids } from "lexicons/api/lexicons"; -import { PubLeafletMyLexicon } from "lexicons/api"; - -// In filterCollections: -filterCollections: [ - ids.PubLeafletMyLexicon, - // ... -], - -// In handleEvent: -if (evt.collection === ids.PubLeafletMyLexicon) { - if (evt.event === "create" || evt.event === "update") { - let record = PubLeafletMyLexicon.validateRecord(evt.record); - if (!record.success) return; - - // Store in database - await supabase.from("my_table").upsert({ - uri: evt.uri.toString(), - data: record.value as Json, - }); - } - if (evt.event === "delete") { - await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); - } -} -``` - -## Publishing Lexicons - -To publish lexicons to an AT Protocol PDS: - -```bash -npm run publish-lexicons -``` - -This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS. diff --git a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts index 131b821f..92ca1bf6 100644 --- a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts +++ b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts @@ -26,6 +26,7 @@ export async function getProfilePosts( `*, comments_on_documents(count), document_mentions_in_bsky(count), + recommends_on_documents(count), documents_in_publications(publications(*))`, ) .like("uri", `at://${did}/%`) @@ -39,18 +40,19 @@ export async function getProfilePosts( ); } - let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([ - query, - supabaseServerClient - .from("publications") - .select("*") - .eq("identity_did", did), - supabaseServerClient - .from("bsky_profiles") - .select("handle") - .eq("did", did) - .single(), - ]); + let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = + await Promise.all([ + query, + supabaseServerClient + .from("publications") + .select("*") + .eq("identity_did", did), + supabaseServerClient + .from("bsky_profiles") + .select("handle") + .eq("did", did) + .single(), + ]); // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces const docs = deduplicateByUriOrdered(rawDocs || []); @@ -82,6 +84,7 @@ export async function getProfilePosts( sort_date: doc.sort_date, comments_on_documents: doc.comments_on_documents, document_mentions_in_bsky: doc.document_mentions_in_bsky, + recommends_on_documents: doc.recommends_on_documents, }, }; diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts index d3996eb6..4ff16591 100644 --- a/app/(home-pages)/reader/getReaderFeed.ts +++ b/app/(home-pages)/reader/getReaderFeed.ts @@ -32,6 +32,7 @@ export async function getReaderFeed( `*, comments_on_documents(count), document_mentions_in_bsky(count), + recommends_on_documents(count), documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, ) .eq( @@ -76,6 +77,7 @@ export async function getReaderFeed( documents: { comments_on_documents: post.comments_on_documents, document_mentions_in_bsky: post.document_mentions_in_bsky, + recommends_on_documents: post.recommends_on_documents, data: normalizedData, uri: post.uri, sort_date: post.sort_date, @@ -112,5 +114,6 @@ export type Post = { sort_date: string; comments_on_documents: { count: number }[] | undefined; document_mentions_in_bsky: { count: number }[] | undefined; + recommends_on_documents: { count: number }[] | undefined; }; }; diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts index ebaffbe0..bb6394c9 100644 --- a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -21,6 +21,7 @@ export async function getDocumentsByTag( `*, comments_on_documents(count), document_mentions_in_bsky(count), + recommends_on_documents(count), documents_in_publications(publications(*))`, ) .contains("data->tags", `["${tag}"]`) @@ -67,6 +68,7 @@ export async function getDocumentsByTag( documents: { comments_on_documents: doc.comments_on_documents, document_mentions_in_bsky: doc.document_mentions_in_bsky, + recommends_on_documents: doc.recommends_on_documents, data: normalizedData, uri: doc.uri, sort_date: doc.sort_date, diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 33b37f07..db2ab8f4 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -105,7 +105,7 @@ export async function GET( }) .select() .single(); - + console.log({ token }); if (token) await setAuthToken(token.id); // Process successful authentication here @@ -114,6 +114,7 @@ export async function GET( console.log("User authenticated as:", session.did); return handleAction(s.action, redirectPath); } catch (e) { + console.log(e); redirect(redirectPath); } } diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index b33b9203..09cc91f4 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -40,7 +40,8 @@ export const get_publication_data = makeRoute({ documents_in_publications(documents( *, comments_on_documents(count), - document_mentions_in_bsky(count) + document_mentions_in_bsky(count), + recommends_on_documents(count) )), publication_subscriptions(*, identities(bsky_profiles(*))), publication_domains(*), @@ -87,6 +88,8 @@ export const get_publication_data = makeRoute({ data: dip.documents.data, commentsCount: dip.documents.comments_on_documents[0]?.count || 0, mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, + recommendsCount: + dip.documents.recommends_on_documents?.[0]?.count || 0, }; }) .filter((d): d is NonNullable => d !== null); diff --git a/app/api/rpc/[command]/get_user_recommendations.ts b/app/api/rpc/[command]/get_user_recommendations.ts new file mode 100644 index 00000000..1c608695 --- /dev/null +++ b/app/api/rpc/[command]/get_user_recommendations.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { getIdentityData } from "actions/getIdentityData"; + +export type GetUserRecommendationsReturnType = Awaited< + ReturnType<(typeof get_user_recommendations)["handler"]> +>; + +export const get_user_recommendations = makeRoute({ + route: "get_user_recommendations", + input: z.object({ + documentUris: z.array(z.string()), + }), + handler: async ({ documentUris }, { supabase }: Pick) => { + const identity = await getIdentityData(); + const currentUserDid = identity?.atp_did; + + if (!currentUserDid || documentUris.length === 0) { + return { + result: {} as Record, + }; + } + + const { data: recommendations } = await supabase + .from("recommends_on_documents") + .select("document") + .eq("recommender_did", currentUserDid) + .in("document", documentUris); + + const recommendedSet = new Set(recommendations?.map((r) => r.document)); + + const result: Record = {}; + for (const uri of documentUris) { + result[uri] = recommendedSet.has(uri); + } + + return { result }; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index df7a865b..c6ba128e 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -14,6 +14,7 @@ import { get_publication_data } from "./get_publication_data"; import { search_publication_names } from "./search_publication_names"; import { search_publication_documents } from "./search_publication_documents"; import { get_profile_data } from "./get_profile_data"; +import { get_user_recommendations } from "./get_user_recommendations"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -41,6 +42,7 @@ let Routes = [ search_publication_names, search_publication_documents, get_profile_data, + get_user_recommendations, ]; export async function POST( req: Request, diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 4cfd5326..eb2e1411 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -87,7 +87,9 @@ export const ManageSubscription = (props: { return ( Manage Subscription +
+ Manage Subscription +
} >
diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 9b3a1058..74e3763b 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -71,6 +71,7 @@ export function CanvasPage({ preferences={preferences} commentsCount={getCommentCount(document.comments_on_documents, pageId)} quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} + recommendsCount={document.recommendsCount} /> { let isMobile = useIsMobile(); return ( @@ -216,8 +219,10 @@ const CanvasMetadata = (props: { {!props.isSubpage && ( @@ -233,6 +238,7 @@ const CanvasMetadata = (props: { data={props.data} profile={props.profile} preferences={props.preferences} + isCanvas /> diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 9dcc4421..36713943 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -18,6 +18,9 @@ import { useIdentityData } from "components/IdentityProvider"; import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; import { EditTiny } from "components/Icons/EditTiny"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { RecommendButton } from "components/RecommendButton"; +import { ButtonSecondary } from "components/Buttons"; +import { Separator } from "components/Layout"; export type InteractionState = { drawerOpen: undefined | boolean; @@ -105,12 +108,18 @@ export function openInteractionDrawer( export const Interactions = (props: { quotesCount: number; commentsCount: number; + recommendsCount: number; className?: string; showComments: boolean; showMentions: boolean; + showRecommends: boolean; pageId?: string; }) => { - const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); + const { + uri: document_uri, + quotesAndMentions, + normalizedDocument, + } = useDocument(); let { identity } = useIdentityData(); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); @@ -124,13 +133,24 @@ export const Interactions = (props: { const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; + let interactionsAvailable = + props.showComments || props.showMentions || props.showRecommends; + return ( -
- {tagCount > 0 && } +
+ {props.showRecommends === false ? null : ( + + )} + {/*MENTIONS BUTTON*/} {props.quotesCount === 0 || props.showMentions === false ? null : ( )} + {/*COMMENT BUTTON*/} {props.showComments === false ? null : ( )} + + {tagCount > 0 && ( + <> + {interactionsAvailable && } + + + )}
); }; @@ -163,12 +191,20 @@ export const Interactions = (props: { export const ExpandedInteractions = (props: { quotesCount: number; commentsCount: number; + recommendsCount: number; className?: string; showComments: boolean; showMentions: boolean; + showRecommends: boolean; pageId?: string; }) => { - const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); + const { + uri: document_uri, + quotesAndMentions, + normalizedDocument, + publication, + leafletId, + } = useDocument(); let { identity } = useIdentityData(); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); @@ -182,7 +218,8 @@ export const ExpandedInteractions = (props: { const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; - let noInteractions = !props.showComments && !props.showMentions; + let noInteractions = + !props.showComments && !props.showMentions && !props.showRecommends; let subscribed = identity?.atp_did && @@ -191,11 +228,6 @@ export const ExpandedInteractions = (props: { (s) => s.identity === identity.atp_did, ); - let isAuthor = - identity && - identity.atp_did === publication?.identity_did && - leafletId; - return (
) : ( - <> -
+
+
+ {props.showRecommends === false ? null : ( + + )} {props.quotesCount === 0 || !props.showMentions ? null : ( - + {props.quotesCount} + + Mention{props.quotesCount > 1 ? "s" : ""} + )} {!props.showComments ? null : ( - + Comment{props.commentsCount > 1 ? "s" : ""} + )}
- + {subscribed && publication && ( + + )} +
)} - {subscribed && publication && ( - - )}
); @@ -313,7 +349,10 @@ const TagList = (props: { className?: string; tags: string[] | undefined }) => {
); }; -export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { +export function getQuoteCount( + quotesAndMentions: { uri: string; link?: string }[], + pageId?: string, +) { return getQuoteCountFromArray(quotesAndMentions, pageId); } @@ -338,7 +377,10 @@ export function getQuoteCountFromArray( } } -export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { +export function getCommentCount( + comments: CommentOnDocument[], + pageId?: string, +) { if (pageId) return comments.filter( (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, @@ -362,7 +404,7 @@ const EditButton = (props: { return ( Edit Post diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts new file mode 100644 index 00000000..d0295f26 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts @@ -0,0 +1,135 @@ +"use server"; + +import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import { TID } from "@atproto/common"; +import { AtUri, Un$Typed } from "@atproto/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; + +type RecommendResult = + | { success: true; uri: string } + | { + success: false; + error: OAuthSessionError | { type: string; message: string }; + }; + +export async function recommendAction(args: { + document: string; +}): Promise { + console.log("recommend action..."); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + let record: Un$Typed = { + subject: args.document, + createdAt: new Date().toISOString(), + }; + + let rkey = TID.nextStr(); + let uri = AtUri.make( + credentialSession.did!, + "pub.leaflet.interactions.recommend", + rkey, + ); + + await agent.pub.leaflet.interactions.recommend.create( + { rkey, repo: credentialSession.did! }, + record, + ); + + let res = await supabaseServerClient.from("recommends_on_documents").upsert({ + uri: uri.toString(), + document: args.document, + recommender_did: credentialSession.did!, + record: { + $type: "pub.leaflet.interactions.recommend", + ...record, + } as unknown as Json, + }); + console.log(res); + + return { + success: true, + uri: uri.toString(), + }; +} + +export async function unrecommendAction(args: { + document: string; +}): Promise { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + // Find the existing recommend record + const { data: existingRecommend } = await supabaseServerClient + .from("recommends_on_documents") + .select("uri") + .eq("document", args.document) + .eq("recommender_did", credentialSession.did!) + .single(); + + if (!existingRecommend) { + return { + success: false, + error: { + type: "not_found", + message: "Recommend not found", + }, + }; + } + + let uri = new AtUri(existingRecommend.uri); + + await agent.pub.leaflet.interactions.recommend.delete({ + rkey: uri.rkey, + repo: credentialSession.did!, + }); + + await supabaseServerClient + .from("recommends_on_documents") + .delete() + .eq("uri", existingRecommend.uri); + + return { + success: true, + uri: existingRecommend.uri, + }; +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 1046025a..04b9d7d9 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -87,8 +87,12 @@ export function LinearDocumentPage({ pageId={pageId} showComments={preferences.showComments !== false} showMentions={preferences.showMentions !== false} - commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} + showRecommends={preferences.showRecommends !== false} + commentsCount={ + getCommentCount(document.comments_on_documents, pageId) || 0 + } quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} + recommendsCount={document.recommendsCount} /> {!hasPageBackground &&
} diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 86216159..1159683a 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -18,7 +18,12 @@ import { ProfilePopover } from "components/ProfilePopover"; export function PostHeader(props: { data: PostPageData; profile: ProfileViewDetailed; - preferences: { showComments?: boolean; showMentions?: boolean }; + preferences: { + showComments?: boolean; + showMentions?: boolean; + showRecommends?: boolean; + }; + isCanvas?: boolean; }) { let { identity } = useIdentityData(); let document = props.data; @@ -84,12 +89,20 @@ export function PostHeader(props: { ) : null}
- + {!props.isCanvas && ( + + )} } /> diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 234f2041..9b2a8a67 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -170,6 +170,7 @@ export type SharedPageProps = { preferences: { showComments?: boolean; showMentions?: boolean; + showRecommends?: boolean; showPrevNext?: boolean; }; pubRecord?: NormalizedPublication | null; @@ -233,6 +234,7 @@ export function PostPages({ preferences: { showComments?: boolean; showMentions?: boolean; + showRecommends?: boolean; showPrevNext?: boolean; }; pollData: PollData[]; diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 9f7f44a2..7f16418a 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -22,7 +22,8 @@ export async function getPostPageData(did: string, rkey: string) { publication_subscriptions(*)) ), document_mentions_in_bsky(*), - leaflets_in_publications(*) + leaflets_in_publications(*), + recommends_on_documents(count) `, ) .or(documentUriFilter(did, rkey)) @@ -33,12 +34,15 @@ export async function getPostPageData(did: string, rkey: string) { if (!document) return null; // Normalize the document record - this is the primary way consumers should access document data - const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); + const normalizedDocument = normalizeDocumentRecord( + document.data, + document.uri, + ); if (!normalizedDocument) return null; // Normalize the publication record - this is the primary way consumers should access publication data const normalizedPublication = normalizePublicationRecord( - document.documents_in_publications[0]?.publications?.record + document.documents_in_publications[0]?.publications?.record, ); // Fetch constellation backlinks for mentions @@ -83,7 +87,10 @@ export async function getPostPageData(did: string, rkey: string) { // Filter and sort documents by publishedAt const sortedDocs = allDocs .map((dip) => { - const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); + const normalizedData = normalizeDocumentRecord( + dip?.documents?.data, + dip?.documents?.uri, + ); return { uri: dip?.documents?.uri, title: normalizedData?.title, @@ -98,7 +105,9 @@ export async function getPostPageData(did: string, rkey: string) { ); // Find current document index - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); + const currentIndex = sortedDocs.findIndex( + (doc) => doc.uri === document.uri, + ); if (currentIndex !== -1) { prevNext = { @@ -122,13 +131,21 @@ export async function getPostPageData(did: string, rkey: string) { // Build explicit publication context for consumers const rawPub = document.documents_in_publications[0]?.publications; - const publication = rawPub ? { - uri: rawPub.uri, - name: rawPub.name, - identity_did: rawPub.identity_did, - record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, - publication_subscriptions: rawPub.publication_subscriptions || [], - } : null; + const publication = rawPub + ? { + uri: rawPub.uri, + name: rawPub.name, + identity_did: rawPub.identity_did, + record: rawPub.record as + | PubLeafletPublication.Record + | SiteStandardPublication.Record + | null, + publication_subscriptions: rawPub.publication_subscriptions || [], + } + : null; + + // Get recommends count from the aggregated query result + const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; return { ...document, @@ -143,6 +160,8 @@ export async function getPostPageData(did: string, rkey: string) { comments: document.comments_on_documents, mentions: document.document_mentions_in_bsky, leafletId: document.leaflets_in_publications[0]?.leaflet || null, + // Recommends data + recommendsCount, }; } diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index b8a24406..a7e4c630 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -60,7 +60,9 @@ export function PublishedPostsList(props: { function PublishedPostItem(props: { doc: PublishedDocument; - publication: NonNullable["data"]>["publication"]>; + publication: NonNullable< + NonNullable["data"]>["publication"] + >; pubRecord: ReturnType; showPageBackground: boolean; }) { @@ -94,10 +96,7 @@ function PublishedPostItem(props: {
{leaflet && leaflet.permission_tokens && ( <> - + @@ -129,9 +128,7 @@ function PublishedPostItem(props: {
{doc.record.description ? ( -

- {doc.record.description} -

+

{doc.record.description}

) : null}
{doc.record.publishedAt ? ( @@ -140,9 +137,12 @@ function PublishedPostItem(props: {
diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx index 076568ef..70ae3d75 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx @@ -29,6 +29,11 @@ export const PostOptions = (props: { ? true : record.preferences.showMentions, ); + let [showRecommends, setShowRecommends] = useState( + record?.preferences?.showRecommends === undefined + ? true + : record.preferences.showRecommends, + ); let [showPrevNext, setShowPrevNext] = useState( record?.preferences?.showPrevNext === undefined ? true @@ -53,6 +58,7 @@ export const PostOptions = (props: { showComments: showComments, showMentions: showMentions, showPrevNext: showPrevNext, + showRecommends: showRecommends, }, }); toast({ type: "success", content: Posts Updated! }); @@ -99,7 +105,21 @@ export const PostOptions = (props: {
Show Mentions
- Display a list of posts on Bluesky that mention your post + Display a list Bluesky mentions about your post +
+
+ + + { + setShowRecommends(!showRecommends); + }} + > +
+
Show Recommends
+
+ Allow readers to recommend/like your post
diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 0a51e1b4..457f38b2 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -38,7 +38,8 @@ export default async function Publication(props: { documents_in_publications(documents( *, comments_on_documents(count), - document_mentions_in_bsky(count) + document_mentions_in_bsky(count), + recommends_on_documents(count) )) `, ) @@ -119,7 +120,9 @@ export default async function Publication(props: { }) .map((doc) => { if (!doc.documents) return null; - const doc_record = normalizeDocumentRecord(doc.documents.data); + const doc_record = normalizeDocumentRecord( + doc.documents.data, + ); if (!doc_record) return null; let uri = new AtUri(doc.documents.uri); let quotes = @@ -128,6 +131,8 @@ export default async function Publication(props: { record?.preferences?.showComments === false ? 0 : doc.documents.comments_on_documents[0].count || 0; + let recommends = + doc.documents.recommends_on_documents?.[0]?.count || 0; let tags = doc_record.tags || []; return ( @@ -143,7 +148,7 @@ export default async function Publication(props: {

-
+

{doc_record.publishedAt && ( )}{" "}

- {comments > 0 || quotes > 0 || tags.length > 0 ? ( - - ) : ( - "" - )} +
diff --git a/app/lish/createPub/CreatePubForm.tsx b/app/lish/createPub/CreatePubForm.tsx index 2dbe924e..749b4397 100644 --- a/app/lish/createPub/CreatePubForm.tsx +++ b/app/lish/createPub/CreatePubForm.tsx @@ -58,6 +58,7 @@ export const CreatePubForm = () => { showComments: true, showMentions: true, showPrevNext: true, + showRecommends: true, }, }); diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index 3672c5f8..f9844796 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -88,6 +88,7 @@ export const EditPubForm = (props: { showComments: showComments, showMentions: showMentions, showPrevNext: showPrevNext, + showRecommends: record?.preferences?.showRecommends ?? true, }, }); toast({ type: "success", content: "Updated!" }); @@ -194,8 +195,6 @@ export const EditPubForm = (props: {

- - ); diff --git a/app/lish/createPub/createPublication.ts b/app/lish/createPub/createPublication.ts index a705a0e6..5adcc7ee 100644 --- a/app/lish/createPub/createPublication.ts +++ b/app/lish/createPub/createPublication.ts @@ -5,10 +5,7 @@ import { PubLeafletPublication, SiteStandardPublication, } from "lexicons/api"; -import { - restoreOAuthSession, - OAuthSessionError, -} from "src/atproto-oauth"; +import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; @@ -76,7 +73,11 @@ export async function createPublication({ // Build record based on publication type let record: SiteStandardPublication.Record | PubLeafletPublication.Record; - let iconBlob: Awaited>["data"]["blob"] | undefined; + let iconBlob: + | Awaited< + ReturnType + >["data"]["blob"] + | undefined; // Upload the icon if provided if (iconFile && iconFile.size > 0) { @@ -97,16 +98,29 @@ export async function createPublication({ ...(iconBlob && { icon: iconBlob }), basicTheme: { $type: "site.standard.theme.basic", - background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, - foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, - accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, - accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, + background: { + $type: "site.standard.theme.color#rgb", + ...PubThemeDefaultsRGB.background, + }, + foreground: { + $type: "site.standard.theme.color#rgb", + ...PubThemeDefaultsRGB.foreground, + }, + accent: { + $type: "site.standard.theme.color#rgb", + ...PubThemeDefaultsRGB.accent, + }, + accentForeground: { + $type: "site.standard.theme.color#rgb", + ...PubThemeDefaultsRGB.accentForeground, + }, }, preferences: { showInDiscover: preferences.showInDiscover, showComments: preferences.showComments, showMentions: preferences.showMentions, showPrevNext: preferences.showPrevNext, + showRecommends: preferences.showRecommends, }, } satisfies SiteStandardPublication.Record; } else { diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index a9bd3e56..350b3f88 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -77,7 +77,9 @@ async function withPublicationUpdate( } const aturi = new AtUri(existingPub.uri); - const publicationType = getPublicationType(aturi.collection) as PublicationType; + const publicationType = getPublicationType( + aturi.collection, + ) as PublicationType; // Normalize existing record const normalizedPub = normalizePublicationRecord(existingPub.record); @@ -128,7 +130,11 @@ interface RecordOverrides { } /** Merges override with existing value, respecting explicit undefined */ -function resolveField(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { +function resolveField( + override: T | undefined, + existing: T | undefined, + hasOverride: boolean, +): T | undefined { return hasOverride ? override : existing; } @@ -146,17 +152,32 @@ function buildLeafletRecord( return { $type: "pub.leaflet.publication", name: overrides.name ?? normalizedPub?.name ?? "", - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), + description: resolveField( + overrides.description, + normalizedPub?.description, + "description" in overrides, + ), + icon: resolveField( + overrides.icon, + normalizedPub?.icon, + "icon" in overrides, + ), + theme: resolveField( + overrides.theme, + normalizedPub?.theme, + "theme" in overrides, + ), base_path: overrides.basePath ?? existingBasePath, - preferences: preferences ? { - $type: "pub.leaflet.publication#preferences", - showInDiscover: preferences.showInDiscover, - showComments: preferences.showComments, - showMentions: preferences.showMentions, - showPrevNext: preferences.showPrevNext, - } : undefined, + preferences: preferences + ? { + $type: "pub.leaflet.publication#preferences", + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + showRecommends: preferences.showRecommends, + } + : undefined, }; } @@ -175,17 +196,36 @@ function buildStandardRecord( return { $type: "site.standard.publication", name: overrides.name ?? normalizedPub?.name ?? "", - description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), - icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), - theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), - basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), + description: resolveField( + overrides.description, + normalizedPub?.description, + "description" in overrides, + ), + icon: resolveField( + overrides.icon, + normalizedPub?.icon, + "icon" in overrides, + ), + theme: resolveField( + overrides.theme, + normalizedPub?.theme, + "theme" in overrides, + ), + basicTheme: resolveField( + overrides.basicTheme, + normalizedPub?.basicTheme, + "basicTheme" in overrides, + ), url: basePath ? `https://${basePath}` : normalizedPub?.url || "", - preferences: preferences ? { - showInDiscover: preferences.showInDiscover, - showComments: preferences.showComments, - showMentions: preferences.showMentions, - showPrevNext: preferences.showPrevNext, - } : undefined, + preferences: preferences + ? { + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + showRecommends: preferences.showRecommends, + } + : undefined, }; } @@ -217,27 +257,30 @@ export async function updatePublication({ iconFile?: File | null; preferences?: Omit; }): Promise { - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { - // Upload icon if provided - let iconBlob = normalizedPub?.icon; - if (iconFile && iconFile.size > 0) { - const buffer = await iconFile.arrayBuffer(); - const uploadResult = await agent.com.atproto.repo.uploadBlob( - new Uint8Array(buffer), - { encoding: iconFile.type }, - ); - if (uploadResult.data.blob) { - iconBlob = uploadResult.data.blob; + return withPublicationUpdate( + uri, + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { + // Upload icon if provided + let iconBlob = normalizedPub?.icon; + if (iconFile && iconFile.size > 0) { + const buffer = await iconFile.arrayBuffer(); + const uploadResult = await agent.com.atproto.repo.uploadBlob( + new Uint8Array(buffer), + { encoding: iconFile.type }, + ); + if (uploadResult.data.blob) { + iconBlob = uploadResult.data.blob; + } } - } - return buildRecord(normalizedPub, existingBasePath, publicationType, { - name, - description, - icon: iconBlob, - preferences, - }); - }); + return buildRecord(normalizedPub, existingBasePath, publicationType, { + name, + description, + icon: iconBlob, + preferences, + }); + }, + ); } export async function updatePublicationBasePath({ @@ -247,11 +290,14 @@ export async function updatePublicationBasePath({ uri: string; base_path: string; }): Promise { - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { - return buildRecord(normalizedPub, existingBasePath, publicationType, { - basePath: base_path, - }); - }); + return withPublicationUpdate( + uri, + async ({ normalizedPub, existingBasePath, publicationType }) => { + return buildRecord(normalizedPub, existingBasePath, publicationType, { + basePath: base_path, + }); + }, + ); } type Color = @@ -275,58 +321,81 @@ export async function updatePublicationTheme({ accentText: Color; }; }): Promise { - return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { - // Build theme object - const themeData = { - $type: "pub.leaflet.publication#theme" as const, - backgroundImage: theme.backgroundImage - ? { - $type: "pub.leaflet.theme.backgroundImage", - image: ( - await agent.com.atproto.repo.uploadBlob( - new Uint8Array(await theme.backgroundImage.arrayBuffer()), - { encoding: theme.backgroundImage.type }, - ) - )?.data.blob, - width: theme.backgroundRepeat || undefined, - repeat: !!theme.backgroundRepeat, - } - : theme.backgroundImage === null - ? undefined - : normalizedPub?.theme?.backgroundImage, - backgroundColor: theme.backgroundColor - ? { - ...theme.backgroundColor, - } - : undefined, - pageWidth: theme.pageWidth, - primary: { - ...theme.primary, - }, - pageBackground: { - ...theme.pageBackground, - }, - showPageBackground: theme.showPageBackground, - accentBackground: { - ...theme.accentBackground, - }, - accentText: { - ...theme.accentText, - }, - }; + return withPublicationUpdate( + uri, + async ({ normalizedPub, existingBasePath, publicationType, agent }) => { + // Build theme object + const themeData = { + $type: "pub.leaflet.publication#theme" as const, + backgroundImage: theme.backgroundImage + ? { + $type: "pub.leaflet.theme.backgroundImage", + image: ( + await agent.com.atproto.repo.uploadBlob( + new Uint8Array(await theme.backgroundImage.arrayBuffer()), + { encoding: theme.backgroundImage.type }, + ) + )?.data.blob, + width: theme.backgroundRepeat || undefined, + repeat: !!theme.backgroundRepeat, + } + : theme.backgroundImage === null + ? undefined + : normalizedPub?.theme?.backgroundImage, + backgroundColor: theme.backgroundColor + ? { + ...theme.backgroundColor, + } + : undefined, + pageWidth: theme.pageWidth, + primary: { + ...theme.primary, + }, + pageBackground: { + ...theme.pageBackground, + }, + showPageBackground: theme.showPageBackground, + accentBackground: { + ...theme.accentBackground, + }, + accentText: { + ...theme.accentText, + }, + }; - // Derive basicTheme from the theme colors for site.standard.publication - const basicTheme: NormalizedPublication["basicTheme"] = { - $type: "site.standard.theme.basic", - background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, - foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, - accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, - accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, - }; + // Derive basicTheme from the theme colors for site.standard.publication + const basicTheme: NormalizedPublication["basicTheme"] = { + $type: "site.standard.theme.basic", + background: { + $type: "site.standard.theme.color#rgb", + r: theme.backgroundColor.r, + g: theme.backgroundColor.g, + b: theme.backgroundColor.b, + }, + foreground: { + $type: "site.standard.theme.color#rgb", + r: theme.primary.r, + g: theme.primary.g, + b: theme.primary.b, + }, + accent: { + $type: "site.standard.theme.color#rgb", + r: theme.accentBackground.r, + g: theme.accentBackground.g, + b: theme.accentBackground.b, + }, + accentForeground: { + $type: "site.standard.theme.color#rgb", + r: theme.accentText.r, + g: theme.accentText.g, + b: theme.accentText.b, + }, + }; - return buildRecord(normalizedPub, existingBasePath, publicationType, { - theme: themeData, - basicTheme, - }); - }); + return buildRecord(normalizedPub, existingBasePath, publicationType, { + theme: themeData, + basicTheme, + }); + }, + ); } diff --git a/components/Canvas.tsx b/components/Canvas.tsx index 0faa3302..118c163e 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -19,10 +19,11 @@ import { Popover } from "./Popover"; import { Separator } from "./Layout"; import { CommentTiny } from "./Icons/CommentTiny"; import { QuoteTiny } from "./Icons/QuoteTiny"; -import { PublicationMetadata } from "./Pages/PublicationMetadata"; +import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; import { useLeafletPublicationData } from "./PageSWRDataProvider"; import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; +import { RecommendTinyEmpty } from "./Icons/RecommendTiny"; export function Canvas(props: { entityID: string; @@ -168,20 +169,34 @@ const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { if (!normalizedPublication) return null; let showComments = normalizedPublication.preferences?.showComments !== false; let showMentions = normalizedPublication.preferences?.showMentions !== false; + let showRecommends = + normalizedPublication.preferences?.showRecommends !== false; return (
+ {showRecommends && ( +
+ — +
+ )} {showComments && (
)} - {showComments && ( + {showMentions && (
)} + {showMentions !== false || + showComments !== false || + showRecommends === false ? ( + + ) : null} + + {!props.isSubpage && ( <> @@ -191,7 +206,7 @@ const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" trigger={} > - + )} diff --git a/components/Icons/RecommendTiny.tsx b/components/Icons/RecommendTiny.tsx new file mode 100644 index 00000000..7ec07c14 --- /dev/null +++ b/components/Icons/RecommendTiny.tsx @@ -0,0 +1,37 @@ +import { Props } from "./Props"; + +export const RecommendTinyFilled = (props: Props) => { + return ( + + + + ); +}; + +export const RecommendTinyEmpty = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx index 30ddefbb..9f393f86 100644 --- a/components/InteractionsPreview.tsx +++ b/components/InteractionsPreview.tsx @@ -7,35 +7,36 @@ import { Tag } from "./Tags"; import { Popover } from "./Popover"; import { TagTiny } from "./Icons/TagTiny"; import { SpeedyLink } from "./SpeedyLink"; +import { RecommendButton } from "./RecommendButton"; export const InteractionPreview = (props: { quotesCount: number; commentsCount: number; + recommendsCount: number; + documentUri: string; tags?: string[]; postUrl: string; showComments: boolean; showMentions: boolean; + showRecommends: boolean; share?: boolean; }) => { let smoker = useSmoker(); let interactionsAvailable = (props.quotesCount > 0 && props.showMentions) || - (props.showComments !== false && props.commentsCount > 0); + (props.showComments !== false && props.commentsCount > 0) || + (props.showRecommends !== false && props.recommendsCount > 0); const tagsCount = props.tags?.length || 0; return ( -
- {tagsCount === 0 ? null : ( - <> - - {interactionsAvailable || props.share ? ( - - ) : null} - +
+ {props.showRecommends === false ? null : ( + )} {!props.showMentions || props.quotesCount === 0 ? null : ( @@ -56,11 +57,16 @@ export const InteractionPreview = (props: { {props.commentsCount} )} - {interactionsAvailable && props.share ? ( - - ) : null} + {tagsCount === 0 ? null : ( + <> + {interactionsAvailable ? : null} + + + )} {props.share && ( <> + + + ); +} diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx index 467d9ad9..d3157d69 100644 --- a/contexts/DocumentContext.tsx +++ b/contexts/DocumentContext.tsx @@ -21,6 +21,7 @@ export type DocumentContextValue = Pick< | "comments" | "mentions" | "leafletId" + | "recommendsCount" >; const DocumentContext = createContext(null); diff --git a/lexicons/api/types/pub/leaflet/publication.ts b/lexicons/api/types/pub/leaflet/publication.ts index 88314a28..51143ee0 100644 --- a/lexicons/api/types/pub/leaflet/publication.ts +++ b/lexicons/api/types/pub/leaflet/publication.ts @@ -1,89 +1,94 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' -import type * as PubLeafletThemeColor from './theme/color' -import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage' +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { CID } from "multiformats/cid"; +import { validate as _validate } from "../../../lexicons"; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from "../../../util"; +import type * as PubLeafletThemeColor from "./theme/color"; +import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage"; const is$typed = _is$typed, - validate = _validate -const id = 'pub.leaflet.publication' + validate = _validate; +const id = "pub.leaflet.publication"; export interface Record { - $type: 'pub.leaflet.publication' - name: string - base_path?: string - description?: string - icon?: BlobRef - theme?: Theme - preferences?: Preferences - [k: string]: unknown + $type: "pub.leaflet.publication"; + name: string; + base_path?: string; + description?: string; + icon?: BlobRef; + theme?: Theme; + preferences?: Preferences; + [k: string]: unknown; } -const hashRecord = 'main' +const hashRecord = "main"; export function isRecord(v: V) { - return is$typed(v, id, hashRecord) + return is$typed(v, id, hashRecord); } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true) + return validate(v, id, hashRecord, true); } export interface Preferences { - $type?: 'pub.leaflet.publication#preferences' - showInDiscover: boolean - showComments: boolean - showMentions: boolean - showPrevNext: boolean + $type?: "pub.leaflet.publication#preferences"; + showInDiscover: boolean; + showComments: boolean; + showMentions: boolean; + showPrevNext: boolean; + showRecommends: boolean; } -const hashPreferences = 'preferences' +const hashPreferences = "preferences"; export function isPreferences(v: V) { - return is$typed(v, id, hashPreferences) + return is$typed(v, id, hashPreferences); } export function validatePreferences(v: V) { - return validate(v, id, hashPreferences) + return validate(v, id, hashPreferences); } export interface Theme { - $type?: 'pub.leaflet.publication#theme' + $type?: "pub.leaflet.publication#theme"; backgroundColor?: | $Typed | $Typed - | { $type: string } - backgroundImage?: PubLeafletThemeBackgroundImage.Main - pageWidth?: number + | { $type: string }; + backgroundImage?: PubLeafletThemeBackgroundImage.Main; + pageWidth?: number; primary?: | $Typed | $Typed - | { $type: string } + | { $type: string }; pageBackground?: | $Typed | $Typed - | { $type: string } - showPageBackground: boolean + | { $type: string }; + showPageBackground: boolean; accentBackground?: | $Typed | $Typed - | { $type: string } + | { $type: string }; accentText?: | $Typed | $Typed - | { $type: string } + | { $type: string }; } -const hashTheme = 'theme' +const hashTheme = "theme"; export function isTheme(v: V) { - return is$typed(v, id, hashTheme) + return is$typed(v, id, hashTheme); } export function validateTheme(v: V) { - return validate(v, id, hashTheme) + return validate(v, id, hashTheme); } diff --git a/lexicons/api/types/site/standard/publication.ts b/lexicons/api/types/site/standard/publication.ts index 2daf12b6..08f80d9c 100644 --- a/lexicons/api/types/site/standard/publication.ts +++ b/lexicons/api/types/site/standard/publication.ts @@ -1,53 +1,58 @@ /** * GENERATED CODE - DO NOT MODIFY */ -import { type ValidationResult, BlobRef } from '@atproto/lexicon' -import { CID } from 'multiformats/cid' -import { validate as _validate } from '../../../lexicons' -import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' -import type * as SiteStandardThemeBasic from './theme/basic' -import type * as PubLeafletPublication from '../../pub/leaflet/publication' +import { type ValidationResult, BlobRef } from "@atproto/lexicon"; +import { CID } from "multiformats/cid"; +import { validate as _validate } from "../../../lexicons"; +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from "../../../util"; +import type * as SiteStandardThemeBasic from "./theme/basic"; +import type * as PubLeafletPublication from "../../pub/leaflet/publication"; const is$typed = _is$typed, - validate = _validate -const id = 'site.standard.publication' + validate = _validate; +const id = "site.standard.publication"; export interface Record { - $type: 'site.standard.publication' - basicTheme?: SiteStandardThemeBasic.Main - theme?: $Typed | { $type: string } - description?: string - icon?: BlobRef - name: string - preferences?: Preferences - url: string - [k: string]: unknown + $type: "site.standard.publication"; + basicTheme?: SiteStandardThemeBasic.Main; + theme?: $Typed | { $type: string }; + description?: string; + icon?: BlobRef; + name: string; + preferences?: Preferences; + url: string; + [k: string]: unknown; } -const hashRecord = 'main' +const hashRecord = "main"; export function isRecord(v: V) { - return is$typed(v, id, hashRecord) + return is$typed(v, id, hashRecord); } export function validateRecord(v: V) { - return validate(v, id, hashRecord, true) + return validate(v, id, hashRecord, true); } export interface Preferences { - $type?: 'site.standard.publication#preferences' - showInDiscover: boolean - showComments: boolean - showMentions: boolean - showPrevNext: boolean + $type?: "site.standard.publication#preferences"; + showInDiscover: boolean; + showComments: boolean; + showMentions: boolean; + showPrevNext: boolean; + showRecommends: boolean; } -const hashPreferences = 'preferences' +const hashPreferences = "preferences"; export function isPreferences(v: V) { - return is$typed(v, id, hashPreferences) + return is$typed(v, id, hashPreferences); } export function validatePreferences(v: V) { - return validate(v, id, hashPreferences) + return validate(v, id, hashPreferences); } diff --git a/lexicons/pub/leaflet/publication.json b/lexicons/pub/leaflet/publication.json index 0627addf..c98b1eb6 100644 --- a/lexicons/pub/leaflet/publication.json +++ b/lexicons/pub/leaflet/publication.json @@ -59,6 +59,10 @@ "showPrevNext": { "type": "boolean", "default": true + }, + "showRecommends": { + "type": "boolean", + "default": true } } }, diff --git a/lexicons/site/standard/publication.json b/lexicons/site/standard/publication.json index b1449052..7fdbcc22 100644 --- a/lexicons/site/standard/publication.json +++ b/lexicons/site/standard/publication.json @@ -58,6 +58,10 @@ "showPrevNext": { "default": false, "type": "boolean" + }, + "showRecommends": { + "default": true, + "type": "boolean" } }, "type": "object" diff --git a/lexicons/src/normalize.ts b/lexicons/src/normalize.ts index d4890e07..4f7294c7 100644 --- a/lexicons/src/normalize.ts +++ b/lexicons/src/normalize.ts @@ -50,7 +50,7 @@ export type NormalizedPublication = { * Checks if the record is a pub.leaflet.document */ export function isLeafletDocument( - record: unknown + record: unknown, ): record is PubLeafletDocument.Record { if (!record || typeof record !== "object") return false; const r = record as Record; @@ -65,7 +65,7 @@ export function isLeafletDocument( * Checks if the record is a site.standard.document */ export function isStandardDocument( - record: unknown + record: unknown, ): record is SiteStandardDocument.Record { if (!record || typeof record !== "object") return false; const r = record as Record; @@ -76,7 +76,7 @@ export function isStandardDocument( * Checks if the record is a pub.leaflet.publication */ export function isLeafletPublication( - record: unknown + record: unknown, ): record is PubLeafletPublication.Record { if (!record || typeof record !== "object") return false; const r = record as Record; @@ -91,7 +91,7 @@ export function isLeafletPublication( * Checks if the record is a site.standard.publication */ export function isStandardPublication( - record: unknown + record: unknown, ): record is SiteStandardPublication.Record { if (!record || typeof record !== "object") return false; const r = record as Record; @@ -106,7 +106,7 @@ function extractRgb( | $Typed | $Typed | { $type: string } - | undefined + | undefined, ): { r: number; g: number; b: number } | undefined { if (!color || typeof color !== "object") return undefined; const c = color as Record; @@ -124,12 +124,13 @@ function extractRgb( * Converts a pub.leaflet theme to a site.standard.theme.basic format */ export function leafletThemeToBasicTheme( - theme: PubLeafletPublication.Theme | undefined + theme: PubLeafletPublication.Theme | undefined, ): SiteStandardThemeBasic.Main | undefined { if (!theme) return undefined; const background = extractRgb(theme.backgroundColor); - const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); + const accent = + extractRgb(theme.accentBackground) || extractRgb(theme.primary); const accentForeground = extractRgb(theme.accentText); // If we don't have the required colors, return undefined @@ -160,7 +161,10 @@ export function leafletThemeToBasicTheme( * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records * @returns A normalized document in site.standard format, or null if invalid/unrecognized */ -export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { +export function normalizeDocument( + record: unknown, + uri?: string, +): NormalizedDocument | null { if (!record || typeof record !== "object") return null; // Pass through site.standard records directly (theme is already in correct format if present) @@ -219,7 +223,7 @@ export function normalizeDocument(record: unknown, uri?: string): NormalizedDocu * @returns A normalized publication in site.standard format, or null if invalid/unrecognized */ export function normalizePublication( - record: unknown + record: unknown, ): NormalizedPublication | null { if (!record || typeof record !== "object") return null; @@ -268,6 +272,7 @@ export function normalizePublication( showComments: record.preferences.showComments, showMentions: record.preferences.showMentions, showPrevNext: record.preferences.showPrevNext, + showRecommends: record.preferences.showRecommends, } : undefined; @@ -290,7 +295,7 @@ export function normalizePublication( * Type guard to check if a normalized document has leaflet content */ export function hasLeafletContent( - doc: NormalizedDocument + doc: NormalizedDocument, ): doc is NormalizedDocument & { content: $Typed; } { @@ -304,7 +309,7 @@ export function hasLeafletContent( * Gets the pages array from a normalized document, handling both formats */ export function getDocumentPages( - doc: NormalizedDocument + doc: NormalizedDocument, ): PubLeafletContent.Main["pages"] | undefined { if (!doc.content) return undefined; diff --git a/lexicons/src/publication.ts b/lexicons/src/publication.ts index 57bbbef9..cf5904d2 100644 --- a/lexicons/src/publication.ts +++ b/lexicons/src/publication.ts @@ -29,6 +29,7 @@ export const PubLeafletPublication: LexiconDoc = { showComments: { type: "boolean", default: true }, showMentions: { type: "boolean", default: true }, showPrevNext: { type: "boolean", default: true }, + showRecommends: { type: "boolean", default: true }, }, }, theme: { diff --git a/package-lock.json b/package-lock.json index 08cf16a1..bffdd153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/functions": "^2.2.12", "@vercel/sdk": "^1.11.4", + "@yornaath/batshit": "^0.14.0", "babel-plugin-react-compiler": "^19.1.0-rc.1", "base64-js": "^1.5.1", "colorjs.io": "^0.5.2", @@ -8661,6 +8662,21 @@ } } }, + "node_modules/@yornaath/batshit": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", + "license": "MIT", + "dependencies": { + "@yornaath/batshit-devtools": "^1.7.1" + } + }, + "node_modules/@yornaath/batshit-devtools": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/package.json b/package.json index 8c589ee7..5e9e2ee4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/functions": "^2.2.12", "@vercel/sdk": "^1.11.4", + "@yornaath/batshit": "^0.14.0", "babel-plugin-react-compiler": "^19.1.0-rc.1", "base64-js": "^1.5.1", "colorjs.io": "^0.5.2",