diff --git a/api/README.md b/api/README.md index beeeef0..fac7cb4 100644 --- a/api/README.md +++ b/api/README.md @@ -11,7 +11,7 @@ https://api.atmo.watch/lexicons https://api.atmo.watch/status ``` -Collection reads, profiles, and `watch.atmo.getCursor` are anonymous. Profile responses include both Bluesky and Popfeed profiles when available. Review responses include materialized `likesCount` and `commentsCount`. List responses include `itemsCount`, `likesCount`, and `commentsCount`, plus optional relation hydration. Likes count distinct actors, while comments count records whose `subjectUri` is the review or list. List items can be filtered by list URI, media identifiers, work type, list type, status, and date. `watch.atmo.notifyOfUpdate` uses AT Protocol service auth with audience `did:web:api.atmo.watch`. +Collection reads, profiles, and `watch.atmo.getCursor` are anonymous. Profile responses include both Bluesky and Popfeed profiles when available. Review responses include materialized `likesCount` and `commentsCount`; `watch.atmo.review.listWrittenRecords` filters out records whose text is empty or whitespace. List responses include `itemsCount`, `likesCount`, and `commentsCount`, plus optional relation hydration. Likes count distinct actors, while comments count records whose `subjectUri` is the review or list. List items can be filtered by list URI, media identifiers, work type, list type, status, and date. `watch.atmo.notifyOfUpdate` uses AT Protocol service auth with audience `did:web:api.atmo.watch`. ## Initial Cloudflare setup diff --git a/api/lex.config.js b/api/lex.config.js index 0bc3545..7d4490b 100644 --- a/api/lex.config.js +++ b/api/lex.config.js @@ -27,7 +27,8 @@ export default defineLexiconConfig({ "social.popfeed.feed.list", "social.popfeed.feed.listItem", "social.popfeed.feed.review", - "social.popfeed.richtext.facet" + "social.popfeed.richtext.facet", + "watch.atmo.review.listRecords" ], }, ], diff --git a/api/lexicons/custom/watch/atmo/review/listWrittenRecords.json b/api/lexicons/custom/watch/atmo/review/listWrittenRecords.json new file mode 100644 index 0000000..c347af5 --- /dev/null +++ b/api/lexicons/custom/watch/atmo/review/listWrittenRecords.json @@ -0,0 +1,65 @@ +{ + "lexicon": 1, + "id": "watch.atmo.review.listWrittenRecords", + "defs": { + "main": { + "type": "query", + "description": "Query social.popfeed.feed.review records with non-empty text", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "boolean", + "description": "Include indexed profile and identity information" + }, + "hydrateLikes": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Number of likes records to embed" + }, + "order": { + "type": "string", + "knownValues": ["asc", "desc"], + "description": "Sort direction" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["records"], + "properties": { + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "watch.atmo.review.listRecords#record" + } + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "watch.atmo.review.listRecords#profileEntry" + } + } + } + } + } + } + } +} diff --git a/api/lexicons/generated/index.ts b/api/lexicons/generated/index.ts index 72d2829..f7689a8 100644 --- a/api/lexicons/generated/index.ts +++ b/api/lexicons/generated/index.ts @@ -8,21 +8,22 @@ import _3 from "../custom/social/popfeed/feed/list.json"; import _4 from "../custom/social/popfeed/feed/listItem.json"; import _5 from "../custom/social/popfeed/feed/review.json"; import _6 from "../custom/social/popfeed/richtext/facet.json"; -import _7 from "../pulled/app/bsky/actor/profile.json"; -import _8 from "../pulled/com/atproto/label/defs.json"; -import _9 from "../pulled/com/atproto/repo/strongRef.json"; -import _10 from "./watch/atmo/comment/getRecord.json"; -import _11 from "./watch/atmo/comment/listRecords.json"; -import _12 from "./watch/atmo/getCursor.json"; -import _13 from "./watch/atmo/getProfile.json"; -import _14 from "./watch/atmo/like/getRecord.json"; -import _15 from "./watch/atmo/like/listRecords.json"; -import _16 from "./watch/atmo/list/getRecord.json"; -import _17 from "./watch/atmo/list/listRecords.json"; -import _18 from "./watch/atmo/listItem/getRecord.json"; -import _19 from "./watch/atmo/listItem/listRecords.json"; -import _20 from "./watch/atmo/notifyOfUpdate.json"; -import _21 from "./watch/atmo/review/getRecord.json"; -import _22 from "./watch/atmo/review/listRecords.json"; +import _7 from "../custom/watch/atmo/review/listWrittenRecords.json"; +import _8 from "../pulled/app/bsky/actor/profile.json"; +import _9 from "../pulled/com/atproto/label/defs.json"; +import _10 from "../pulled/com/atproto/repo/strongRef.json"; +import _11 from "./watch/atmo/comment/getRecord.json"; +import _12 from "./watch/atmo/comment/listRecords.json"; +import _13 from "./watch/atmo/getCursor.json"; +import _14 from "./watch/atmo/getProfile.json"; +import _15 from "./watch/atmo/like/getRecord.json"; +import _16 from "./watch/atmo/like/listRecords.json"; +import _17 from "./watch/atmo/list/getRecord.json"; +import _18 from "./watch/atmo/list/listRecords.json"; +import _19 from "./watch/atmo/listItem/getRecord.json"; +import _20 from "./watch/atmo/listItem/listRecords.json"; +import _21 from "./watch/atmo/notifyOfUpdate.json"; +import _22 from "./watch/atmo/review/getRecord.json"; +import _23 from "./watch/atmo/review/listRecords.json"; -export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22]; +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23]; diff --git a/api/src/contrail.config.ts b/api/src/contrail.config.ts index ff3d81a..7ee26bb 100644 --- a/api/src/contrail.config.ts +++ b/api/src/contrail.config.ts @@ -1,4 +1,4 @@ -import type { ContrailConfig } from '@atmo-dev/contrail'; +import { getDialect, type ContrailConfig } from '@atmo-dev/contrail'; export const config: ContrailConfig = { namespace: 'watch.atmo', @@ -24,6 +24,14 @@ export const config: ContrailConfig = { creativeWorkType: {}, 'identifiers.tmdbId': {} }, + pipelineQueries: { + listWrittenRecords: async (db) => { + const text = getDialect(db).jsonExtract('r.record', 'text'); + const whitespace = + 'CHAR(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279)'; + return { conditions: [`TRIM(COALESCE(${text}, ''), ${whitespace}) <> ''`] }; + } + }, relations: { likes: { collection: 'like', diff --git a/api/src/lexicon-types/index.ts b/api/src/lexicon-types/index.ts index 28be0d9..303f4ab 100644 --- a/api/src/lexicon-types/index.ts +++ b/api/src/lexicon-types/index.ts @@ -14,3 +14,4 @@ export * as WatchAtmoListItemListRecords from "./types/watch/atmo/listItem/listR export * as WatchAtmoNotifyOfUpdate from "./types/watch/atmo/notifyOfUpdate.js"; export * as WatchAtmoReviewGetRecord from "./types/watch/atmo/review/getRecord.js"; export * as WatchAtmoReviewListRecords from "./types/watch/atmo/review/listRecords.js"; +export * as WatchAtmoReviewListWrittenRecords from "./types/watch/atmo/review/listWrittenRecords.js"; diff --git a/api/src/lexicon-types/types/watch/atmo/review/listWrittenRecords.ts b/api/src/lexicon-types/types/watch/atmo/review/listWrittenRecords.ts new file mode 100644 index 0000000..ffe2ac9 --- /dev/null +++ b/api/src/lexicon-types/types/watch/atmo/review/listWrittenRecords.ts @@ -0,0 +1,71 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; +import * as WatchAtmoReviewListRecords from "./listRecords.js"; + +const _mainSchema = /*#__PURE__*/ v.query( + "watch.atmo.review.listWrittenRecords", + { + "params": /*#__PURE__*/ v.object( + { + "cursor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Number of likes records to embed + * @minimum 1 + * @maximum 50 + */ + "hydrateLikes": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.integer(), + [/*#__PURE__*/ v.integerRange(1, 50)] + )), + /** + * @minimum 1 + * @maximum 200 + * @default 50 + */ + "limit": /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.integer(), + [/*#__PURE__*/ v.integerRange(1, 200)] + ), + 50 + ), + /** + * Sort direction + */ + "order": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>()), + /** + * Include indexed profile and identity information + */ + "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + } + ), + "output": { + "type": "lex", + "schema": /*#__PURE__*/ v.object( + { + "cursor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get "profiles"() { + return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(WatchAtmoReviewListRecords.profileEntrySchema)) + }, + get "records"() { + return /*#__PURE__*/ v.array(WatchAtmoReviewListRecords.recordSchema) + }, + } + ), + } + } +); +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} + +export interface $output extends v.InferXRPCBodyInput {} +declare module '@atcute/lexicons/ambient' { + interface XRPCQueries { + "watch.atmo.review.listWrittenRecords": mainSchema; + } +} diff --git a/contrail.lock.json b/contrail.lock.json index ceec9c1..ef2b3a1 100644 --- a/contrail.lock.json +++ b/contrail.lock.json @@ -3,8 +3,8 @@ "version": 1, "endpoint": "https://api.atmo.watch", "namespace": "watch.atmo", - "contractDigest": "sha256:331d01ac6beadd756e777004daf2a4e278bb3c864e91450bf7f974c4547dedd9", - "lexiconDigest": "sha256:f4f7b4ddbb358fc1c701329b0bdde8f085bb17c238197c91f8a2a62233343afd", + "contractDigest": "sha256:edfc8edad45272bc6a66e56d2ea4bc61d1d7f3724304fdd492f9486c33d4032c", + "lexiconDigest": "sha256:b81b7755af0e9755979eb3846efa10b1e7032c40d5682590a7aec7de0a651bb1", "methods": [ "watch.atmo.comment.getRecord", "watch.atmo.comment.listRecords", @@ -17,7 +17,8 @@ "watch.atmo.listItem.getRecord", "watch.atmo.listItem.listRecords", "watch.atmo.review.getRecord", - "watch.atmo.review.listRecords" + "watch.atmo.review.listRecords", + "watch.atmo.review.listWrittenRecords" ], "collections": [ "app.bsky.actor.profile", diff --git a/src/lib/components/Review.svelte b/src/lib/components/Review.svelte index 2db3aae..f97a90e 100644 --- a/src/lib/components/Review.svelte +++ b/src/lib/components/Review.svelte @@ -94,7 +94,7 @@ {#if imageUrl} ()), + /** + * Include indexed profile and identity information + */ + "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + } + ), + "output": { + "type": "lex", + "schema": /*#__PURE__*/ v.object( + { + "cursor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get "profiles"() { + return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(WatchAtmoReviewListRecords.profileEntrySchema)) + }, + get "records"() { + return /*#__PURE__*/ v.array(WatchAtmoReviewListRecords.recordSchema) + }, + } + ), + } + } +); +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} +export const mainSchema = _mainSchema as mainSchema; + +export interface $params extends v.InferInput {} + +export interface $output extends v.InferXRPCBodyInput {} +declare module '@atcute/lexicons/ambient' { + interface XRPCQueries { + "watch.atmo.review.listWrittenRecords": mainSchema; + } +} diff --git a/src/lib/reviews.server.ts b/src/lib/reviews.server.ts index 2483ea0..633af92 100644 --- a/src/lib/reviews.server.ts +++ b/src/lib/reviews.server.ts @@ -7,8 +7,8 @@ import type * as ReviewListRecords from '$lib/contrail/types/types/watch/atmo/re import type { ActorSummary, MediaImage, - MediaSummary, ReviewCardModel, + ReviewFeedPage, ReviewCommentModel, SupportedCreativeWorkType } from '$lib/types'; @@ -203,31 +203,45 @@ export async function getViewerReviewLikes(viewerDid: Did | null | undefined) { return viewerLikes; } -export async function getRecentlyReviewedMedia(): Promise { - const response = await contrail.get('watch.atmo.review.listRecords', { - params: { limit: 200, order: 'desc' } +export async function getRecentReviewsPage({ + cursor, + limit, + viewerDid +}: { + cursor?: string; + limit: number; + viewerDid?: Did | null; +}): Promise { + const response = await contrail.get('watch.atmo.review.listWrittenRecords', { + params: { + cursor, + limit, + order: 'desc', + profiles: true, + ...(viewerDid ? { hydrateLikes: 50 } : {}) + } }); if (!response.ok) { throw new Error(`Could not load recent reviews from Contrail (${response.status})`); } - const seen = new Set(); - const media: MediaSummary[] = []; - - for (const record of response.data.records) { - const review = toReview(record); - if (!review?.media.poster) continue; - - const key = `${review.media.creativeWorkType}:${review.media.tmdbId}`; - if (seen.has(key)) continue; + const profiles = response.data.profiles ?? []; + const reviews = response.data.records.flatMap((record) => { + const author = getReviewAuthor(record.did, profiles); + const review = toReview(record, author.handle); + if (!review?.text.trim()) return []; - seen.add(key); - media.push(review.media); - if (media.length === 20) break; - } + return [ + { + ...review, + author, + viewerLikeUri: record.likes?.find((like) => like.did === viewerDid)?.uri ?? null + } + ]; + }); - return media; + return { reviews, cursor: response.data.cursor ?? null }; } export async function getMediaReviews( diff --git a/src/lib/types.ts b/src/lib/types.ts index 188108d..92ad876 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,6 +68,11 @@ export type ReviewCardModel = { viewerLikeUri?: string | null; }; +export type ReviewFeedPage = { + reviews: ReviewCardModel[]; + cursor: string | null; +}; + export type ReviewCommentModel = { uri: string; author: ActorSummary; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 428f791..3c3a0a9 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,15 +1,15 @@ import type { PageServerLoad } from './$types'; -import { getRecentlyReviewedMedia } from '$lib/reviews.server'; +import { getRecentReviewsPage } from '$lib/reviews.server'; import { getHomePage } from '$lib/tmdb.server'; -export const load: PageServerLoad = async () => { - const [homePage, recentlyReviewedInAtmosphere] = await Promise.all([ +export const load: PageServerLoad = async ({ locals }) => { + const [homePage, recentReviews] = await Promise.all([ getHomePage(), - getRecentlyReviewedMedia().catch((cause) => { - console.error('Could not load recently reviewed media from Contrail', cause); - return []; + getRecentReviewsPage({ limit: 5, viewerDid: locals.did }).catch((cause) => { + console.error('Could not load recent reviews from Contrail', cause); + return { reviews: [], cursor: null }; }) ]); - return { ...homePage, recentlyReviewedInAtmosphere }; + return { ...homePage, recentReviews }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d12131b..c242ac6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,17 +1,73 @@ @@ -27,18 +83,67 @@ - {#if collections.length > 0} -
- {#each collections as collection, index (collection.title)} - 0 ? 'border-t border-white/10 pt-8' : undefined} - /> - {/each} + {#if collections.length > 0 || reviews.length > 0 || cursor} +
+ {#if collections.length > 0} +
+ {#each collections as collection, index (collection.title)} + 0 ? 'border-t border-white/10 pt-8' : undefined} + /> + {/each} +
+ {/if} + + {#if reviews.length > 0 || cursor} +
0 ? 'mt-12 border-t border-white/10 pt-8' : ''}`} + > +

+ Recently reviewed in the atmosphere +

+ + {#if reviews.length > 0} +
+ {#each reviews as review (review.uri)} + + {/each} +
+ {/if} + + {#if cursor} +
+ {#if loading} + Loading more reviews… + {:else if loadError} + + {loadError} + + + {:else} + More reviews load as you scroll. + {/if} +
+ {/if} +
+ {/if}
{:else} -

No movies or shows are available right now.

+

+ No movies, shows, or reviews are available right now. +

{/if} diff --git a/src/routes/api/reviews/+server.ts b/src/routes/api/reviews/+server.ts new file mode 100644 index 0000000..b25d723 --- /dev/null +++ b/src/routes/api/reviews/+server.ts @@ -0,0 +1,26 @@ +import { json } from '@sveltejs/kit'; +import { getRecentReviewsPage } from '$lib/reviews.server'; +import type { RequestHandler } from './$types'; + +const PAGE_SIZE = 10; +const MAX_CURSOR_LENGTH = 2_048; + +export const GET: RequestHandler = async ({ url, locals }) => { + const cursor = url.searchParams.get('cursor'); + if (!cursor || cursor.length > MAX_CURSOR_LENGTH) { + return json({ message: 'Invalid review cursor' }, { status: 400 }); + } + + try { + return json( + await getRecentReviewsPage({ + cursor, + limit: PAGE_SIZE, + viewerDid: locals.did + }) + ); + } catch (cause) { + console.error('Could not load more recent reviews from Contrail', cause); + return json({ message: 'Could not load more reviews' }, { status: 502 }); + } +};