From 62f65bfe9d061d1b8e2f2fff0c327483bf514596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicole=20Miko=C5=82ajczyk?= Date: Thu, 29 May 2025 14:54:12 +0200 Subject: [PATCH] pl-api: pixelfed stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicole Mikołajczyk --- packages/pl-api/lib/client.ts | 130 ++++++++++++++++++ packages/pl-api/lib/entities/index.ts | 3 + .../lib/entities/story-carousel-item.ts | 30 ++++ packages/pl-api/lib/entities/story-media.ts | 21 +++ packages/pl-api/lib/entities/story-profile.ts | 45 ++++++ packages/pl-api/lib/features.ts | 19 +++ packages/pl-api/lib/params/stories.ts | 26 ++++ 7 files changed, 274 insertions(+) create mode 100644 packages/pl-api/lib/entities/story-carousel-item.ts create mode 100644 packages/pl-api/lib/entities/story-media.ts create mode 100644 packages/pl-api/lib/entities/story-profile.ts create mode 100644 packages/pl-api/lib/params/stories.ts diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 69c73d9e3..b18f26ea1 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -76,6 +76,9 @@ import { statusEditSchema, statusSchema, statusSourceSchema, + storyCarouselItemSchema, + storyMediaSchema, + storyProfileSchema, streamingEventSchema, subscriptionDetailsSchema, subscriptionInvoiceSchema, @@ -248,6 +251,7 @@ import type { GetStatusQuotesParams, GetStatusReferencesParams, } from './params/statuses'; +import type { CreateStoryParams, CreateStoryPollParams, CropStoryPhotoParams, StoryReportType } from './params/stories'; import type { AntennaTimelineParams, BubbleTimelineParams, @@ -5767,6 +5771,132 @@ class PlApiClient { }, }; + public readonly stories = { + getRecentStories: async () => { + const response = await this.request('/api/web/stories/v1/recent'); + + return v.parse(filteredArray(storyCarouselItemSchema), response.json); + }, + + getStoryViewers: async (storyId: string) => { + const response = await this.request('/api/web/stories/v1/viewers', { + params: { sid: storyId }, + }); + + return v.parse(filteredArray(accountSchema), response.json); + }, + + getStoriesForProfile: async (accountId: string) => { + const response = await this.request(`/api/web/stories/v1/profile/${accountId}`); + + return v.parse(filteredArray(storyProfileSchema), response.json); + }, + + storyExists: async (accountId: string) => { + const response = await this.request(`/api/web/stories/v1/exists/${accountId}`); + + return v.parse(v.boolean(), response.json); + }, + + getStoryPollResults: async (storyId: string) => { + const response = await this.request('/api/web/stories/v1/poll/results', { + params: { sid: storyId }, + }); + + return v.parse(v.array(v.number()), response.json); + }, + + markStoryAsViewed: async (storyId: string) => { + const response = await this.request<{}>('/api/web/stories/v1/viewed', { + method: 'POST', + body: { id: storyId }, + }); + + return response.json; + }, + + createStoryReaction: async (storyId: string, emoji: string) => { + const response = await this.request<{}>('/api/web/stories/v1/react', { + method: 'POST', + body: { sid: storyId, reaction: emoji }, + }); + + return response.json; + }, + + createStoryComment: async (storyId: string, comment: string) => { + const response = await this.request<{}>('/api/web/stories/v1/comment', { + method: 'POST', + body: { sid: storyId, caption: comment }, + }); + + return response.json; + }, + + createStoryPoll: async (params: CreateStoryPollParams) => { + const response = await this.request<{}>('/api/web/stories/v1/publish/poll', { + method: 'POST', + body: params, + }); + + return response.json; + }, + + storyPollVote: async (storyId: string, choiceId: number) => { + const response = await this.request<{}>('/api/web/stories/v1/publish/poll', { + method: 'POST', + body: { sid: storyId, ci: choiceId }, + }); + + return response.json; + }, + + reportStory: async (storyId: string, type: StoryReportType) => { + const response = await this.request<{}>('/api/web/stories/v1/report', { + method: 'POST', + body: { id: storyId, type }, + }); + + return response.json; + }, + + addMedia: async (file: File) => { + const response = await this.request('/api/web/stories/v1/add', { + method: 'POST', + body: { file }, + contentType: '', + }); + + return v.parse(storyMediaSchema, response.json); + }, + + cropPhoto: async (mediaId: string, params: CropStoryPhotoParams) => { + const response = await this.request<{}>('/api/web/stories/v1/crop', { + method: 'POST', + body: { media_id: mediaId, ...params }, + }); + + return response.json; + }, + + createStory: async (mediaId: string, params: CreateStoryParams) => { + const response = await this.request<{}>('/api/web/stories/v1/publish', { + method: 'POST', + body: { media_id: mediaId, ...params }, + }); + + return response.json; + }, + + deleteStory: async (storyId: string) => { + const response = await this.request<{}>(`/api/web/stories/v1/delete/${storyId}`, { + method: 'DELETE', + }); + + return response.json; + }, + }; + /** Routes that are not part of any stable release */ public readonly experimental = { admin: { diff --git a/packages/pl-api/lib/entities/index.ts b/packages/pl-api/lib/entities/index.ts index 0422e5fec..0a664f236 100644 --- a/packages/pl-api/lib/entities/index.ts +++ b/packages/pl-api/lib/entities/index.ts @@ -76,6 +76,9 @@ export * from './shout-message'; export * from './status'; export * from './status-edit'; export * from './status-source'; +export * from './story-carousel-item'; +export * from './story-media'; +export * from './story-profile'; export * from './streaming-event'; export * from './subscription-details'; export * from './subscription-invoice'; diff --git a/packages/pl-api/lib/entities/story-carousel-item.ts b/packages/pl-api/lib/entities/story-carousel-item.ts new file mode 100644 index 000000000..241b48698 --- /dev/null +++ b/packages/pl-api/lib/entities/story-carousel-item.ts @@ -0,0 +1,30 @@ +import * as v from 'valibot'; + +/** + * @category Schemas + */ +const storyCarouselItemSchema = v.pipe(v.any(), v.transform((item) => ({ + account_id: item.pid, + story_id: item.sid, + ...item, +})), v.object({ + account_id: v.string(), + avatar: v.string(), + local: v.boolean(), + username: v.string(), + latest: v.object({ + id: v.pipe(v.unknown(), v.transform(String)), + type: v.string(), + preview_url: v.string(), + }), + url: v.string(), + seen: v.boolean(), + story_id: v.pipe(v.unknown(), v.transform(String)), +})); + +/** + * @category Entity types + */ +type StoryCarouselItem = v.InferOutput; + +export { storyCarouselItemSchema, type StoryCarouselItem }; diff --git a/packages/pl-api/lib/entities/story-media.ts b/packages/pl-api/lib/entities/story-media.ts new file mode 100644 index 000000000..0f42fa4b0 --- /dev/null +++ b/packages/pl-api/lib/entities/story-media.ts @@ -0,0 +1,21 @@ +import * as v from 'valibot'; + +/** + * @category Schemas + */ +const storyMediaSchema = v.pipe(v.any(), v.transform((media) => ({ + id: media.media_id, + url: media.media_url, + type: media.media_type, +})), v.object({ + id: v.string(), + url: v.string(), + type: v.picklist(['photo', 'video']), +})); + +/** + * @category Entity types + */ +type StoryMedia = v.InferOutput; + +export { storyMediaSchema, type StoryMedia }; diff --git a/packages/pl-api/lib/entities/story-profile.ts b/packages/pl-api/lib/entities/story-profile.ts new file mode 100644 index 000000000..b7a9430c8 --- /dev/null +++ b/packages/pl-api/lib/entities/story-profile.ts @@ -0,0 +1,45 @@ +import * as v from 'valibot'; + +import { accountSchema } from './account'; +import { datetimeSchema, filteredArray } from './utils'; + +/** + * @category Schemas + */ +const storyNodeSchema = v.object({ + id: v.string(), + type: v.string(), + duration: v.number(), + src: v.string(), + created_at: datetimeSchema, + expires_at: datetimeSchema, + view_count: v.fallback(v.nullable(v.number()), null), + seen: v.boolean(), + progress: v.number(), + can_reply: v.boolean(), + can_react: v.boolean(), + question: v.optional(v.string(), undefined), + options: v.optional(v.array(v.string()), undefined), + voted: v.optional(v.boolean(), undefined), + voted_index: v.optional(v.number(), undefined), +}); + +/** + * @category Entity types + */ +type StoryNode = v.InferOutput; + +/** + * @category Schemas + */ +const storyProfileSchema = v.object({ + account: accountSchema, + nodes: filteredArray(storyNodeSchema), +}); + +/** + * @category Entity types + */ +type StoryProfile = v.InferOutput; + +export { storyNodeSchema, type StoryNode, storyProfileSchema, type StoryProfile }; diff --git a/packages/pl-api/lib/features.ts b/packages/pl-api/lib/features.ts index 5d885fbae..bffe9e883 100644 --- a/packages/pl-api/lib/features.ts +++ b/packages/pl-api/lib/features.ts @@ -1556,6 +1556,25 @@ const getFeatures = (instance: Instance) => { */ statusDislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'), + /** + * @see GET /api/web/stories/v1/recent + * @see GET /api/web/stories/v1/viewers + * @see GET /api/web/stories/v1/profile/:id + * @see GET /api/web/stories/v1/exists/:id + * @see GET /api/web/stories/v1/poll/results + * @see POST /api/web/stories/v1/viewed + * @see POST /api/web/stories/v1/react + * @see POST /api/web/stories/v1/comment + * @see POST /api/web/stories/v1/publish/poll + * @see POST /api/web/stories/v1/poll/vote + * @see POST /api/web/stories/v1/report + * @see POST /api/web/stories/v1/add + * @see POST /api/web/stories/v1/crop + * @see POST /api/web/stories/v1/publish + * @see DELETE /api/web/stories/v1/delete/:id + */ + stories: v.software === PIXELFED, + /** * @see GET /api/v1/accounts/:id/subscribers * @see POST /api/v1/subscriptions diff --git a/packages/pl-api/lib/params/stories.ts b/packages/pl-api/lib/params/stories.ts new file mode 100644 index 000000000..eb68782aa --- /dev/null +++ b/packages/pl-api/lib/params/stories.ts @@ -0,0 +1,26 @@ +interface CreateStoryPollParams { + /** From 6 to 140 characters. */ + question: string; + /** Between 2 and 4 answers. */ + answers: string; + can_reply: boolean; + can_react: boolean; +} + +type StoryReportType = 'spam' | 'sensitive' | 'abusive' | 'underage' | 'copyright' | 'impersonation' | 'scam' | 'terrorism'; + +interface CropStoryPhotoParams { + width: number; + height: number; + x: number; + y: number; +} + +interface CreateStoryParams { + /** Between 3 and 120 (in seconds). */ + duration: number; + can_reply: boolean; + can_react: boolean; +} + +export type { CreateStoryPollParams, StoryReportType, CropStoryPhotoParams, CreateStoryParams };