diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index 6b6017d049d..eada4e53897 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -98,7 +98,9 @@ "#adultContentPref", "#contentLabelPref", "#savedFeedsPref", - "#personalDetailsPref" + "#personalDetailsPref", + "#feedViewPref", + "#threadViewPref" ] } }, @@ -149,6 +151,50 @@ "description": "The birth date of the owner of the account." } } + }, + "feedViewPref": { + "type": "object", + "required": ["feed"], + "properties": { + "feed": { + "type": "string", + "description": "The URI of the feed, or an identifier which describes the feed." + }, + "hideReplies": { + "type": "boolean", + "description": "Hide replies in the feed." + }, + "hideRepliesByUnfollowed": { + "type": "boolean", + "description": "Hide replies in the feed if they are not by followed users." + }, + "hideRepliesByLikeCount": { + "type": "integer", + "description": "Hide replies in the feed if they do not have this number of likes." + }, + "hideReposts": { + "type": "boolean", + "description": "Hide reposts in the feed." + }, + "hideQuotePosts": { + "type": "boolean", + "description": "Hide quote posts in the feed." + } + } + }, + "threadViewPref": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "description": "Sorting mode.", + "knownValues": ["oldest", "newest", "most-likes", "random"] + }, + "prioritizeFollowedUsers": { + "type": "boolean", + "description": "Show followed users at the top of all replies." + } + } } } } diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index b7dd1bc1931..7ccbf43f71b 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -8,6 +8,18 @@ import { } from './client' import { BskyPreferences, BskyLabelPreference } from './types' +const FEED_VIEW_PREF_DEFAULTS = { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, +} +const THREAD_VIEW_PREF_DEFAULTS = { + sort: 'oldest', + prioritizeFollowedUsers: true, +} + declare global { interface Array { findLast( @@ -254,6 +266,12 @@ export class BskyAgent extends AtpAgent { saved: undefined, pinned: undefined, }, + feedViewPrefs: { + home: { + ...FEED_VIEW_PREF_DEFAULTS, + }, + }, + threadViewPrefs: { ...THREAD_VIEW_PREF_DEFAULTS }, adultContentEnabled: false, contentLabels: {}, birthDate: undefined, @@ -289,6 +307,18 @@ export class BskyAgent extends AtpAgent { if (pref.birthDate) { prefs.birthDate = new Date(pref.birthDate) } + } else if ( + AppBskyActorDefs.isFeedViewPref(pref) && + AppBskyActorDefs.validateFeedViewPref(pref).success + ) { + const { $type, feed, ...v } = pref + prefs.feedViewPrefs[pref.feed] = { ...FEED_VIEW_PREF_DEFAULTS, ...v } + } else if ( + AppBskyActorDefs.isThreadViewPref(pref) && + AppBskyActorDefs.validateThreadViewPref(pref).success + ) { + const { $type, ...v } = pref + prefs.threadViewPrefs = { ...prefs.threadViewPrefs, ...v } } } return prefs @@ -406,6 +436,46 @@ export class BskyAgent extends AtpAgent { .concat([personalDetailsPref]) }) } + + async setFeedViewPrefs( + feed: string, + pref: Omit, + ) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.findLast( + (pref) => + AppBskyActorDefs.isFeedViewPref(pref) && + AppBskyActorDefs.validateFeedViewPref(pref).success && + pref.feed === feed, + ) + if (existing) { + pref = { ...existing, ...pref } + } + return prefs + .filter( + (p) => !AppBskyActorDefs.isFeedViewPref(pref) || p.feed !== feed, + ) + .concat([{ ...pref, $type: 'app.bsky.actor.defs#feedViewPref', feed }]) + }) + } + + async setThreadViewPrefs( + pref: Omit, + ) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.findLast( + (pref) => + AppBskyActorDefs.isThreadViewPref(pref) && + AppBskyActorDefs.validateThreadViewPref(pref).success, + ) + if (existing) { + pref = { ...existing, ...pref } + } + return prefs + .filter((p) => !AppBskyActorDefs.isThreadViewPref(p)) + .concat([{ ...pref, $type: 'app.bsky.actor.defs#threadViewPref' }]) + }) + } } /** diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 7ee31c74e62..48b1276adc8 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -3687,6 +3687,8 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', 'lex:app.bsky.actor.defs#personalDetailsPref', + 'lex:app.bsky.actor.defs#feedViewPref', + 'lex:app.bsky.actor.defs#threadViewPref', ], }, }, @@ -3743,6 +3745,53 @@ export const schemaDict = { }, }, }, + feedViewPref: { + type: 'object', + required: ['feed'], + properties: { + feed: { + type: 'string', + description: + 'The URI of the feed, or an identifier which describes the feed.', + }, + hideReplies: { + type: 'boolean', + description: 'Hide replies in the feed.', + }, + hideRepliesByUnfollowed: { + type: 'boolean', + description: + 'Hide replies in the feed if they are not by followed users.', + }, + hideRepliesByLikeCount: { + type: 'integer', + description: + 'Hide replies in the feed if they do not have this number of likes.', + }, + hideReposts: { + type: 'boolean', + description: 'Hide reposts in the feed.', + }, + hideQuotePosts: { + type: 'boolean', + description: 'Hide quote posts in the feed.', + }, + }, + }, + threadViewPref: { + type: 'object', + properties: { + sort: { + type: 'string', + description: 'Sorting mode.', + knownValues: ['oldest', 'newest', 'most-likes', 'random'], + }, + prioritizeFollowedUsers: { + type: 'boolean', + description: 'Show followed users at the top of all replies.', + }, + }, + }, }, }, AppBskyActorGetPreferences: { diff --git a/packages/api/src/client/types/app/bsky/actor/defs.ts b/packages/api/src/client/types/app/bsky/actor/defs.ts index 7d3c9fcaac6..340010680d0 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -109,6 +109,8 @@ export type Preferences = ( | ContentLabelPref | SavedFeedsPref | PersonalDetailsPref + | FeedViewPref + | ThreadViewPref | { $type: string; [k: string]: unknown } )[] @@ -182,3 +184,51 @@ export function isPersonalDetailsPref(v: unknown): v is PersonalDetailsPref { export function validatePersonalDetailsPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#personalDetailsPref', v) } + +export interface FeedViewPref { + /** The URI of the feed, or an identifier which describes the feed. */ + feed: string + /** Hide replies in the feed. */ + hideReplies?: boolean + /** Hide replies in the feed if they are not by followed users. */ + hideRepliesByUnfollowed?: boolean + /** Hide replies in the feed if they do not have this number of likes. */ + hideRepliesByLikeCount?: number + /** Hide reposts in the feed. */ + hideReposts?: boolean + /** Hide quote posts in the feed. */ + hideQuotePosts?: boolean + [k: string]: unknown +} + +export function isFeedViewPref(v: unknown): v is FeedViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#feedViewPref' + ) +} + +export function validateFeedViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#feedViewPref', v) +} + +export interface ThreadViewPref { + /** Sorting mode. */ + sort?: 'oldest' | 'newest' | 'most-likes' | 'random' | (string & {}) + /** Show followed users at the top of all replies. */ + prioritizeFollowedUsers?: boolean + [k: string]: unknown +} + +export function isThreadViewPref(v: unknown): v is ThreadViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#threadViewPref' + ) +} + +export function validateThreadViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#threadViewPref', v) +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 0310d6743b8..985ceb44c31 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,4 +1,5 @@ import { LabelPreference } from './moderation/types' +import { AppBskyActorDefs } from './client' /** * Used by the PersistSessionHandler to indicate what change occurred @@ -87,6 +88,8 @@ export interface BskyPreferences { saved?: string[] pinned?: string[] } + feedViewPrefs: Record> + threadViewPrefs: Omit adultContentEnabled: boolean contentLabels: Record birthDate: Date | undefined diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 24b40153458..33a416574d8 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -216,6 +216,19 @@ describe('agent', () => { adultContentEnabled: false, contentLabels: {}, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.setAdultContentEnabled(true) @@ -224,6 +237,19 @@ describe('agent', () => { adultContentEnabled: true, contentLabels: {}, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.setAdultContentEnabled(false) @@ -232,6 +258,19 @@ describe('agent', () => { adultContentEnabled: false, contentLabels: {}, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.setContentLabelPref('impersonation', 'warn') @@ -242,6 +281,19 @@ describe('agent', () => { impersonation: 'warn', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.setContentLabelPref('spam', 'show') // will convert to 'ignore' @@ -254,6 +306,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.addSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -268,6 +333,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -282,6 +360,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.removePinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -296,6 +387,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -310,6 +414,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -324,6 +441,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake2') @@ -344,6 +474,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -358,6 +501,19 @@ describe('agent', () => { spam: 'ignore', }, birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) @@ -372,6 +528,175 @@ describe('agent', () => { spam: 'ignore', }, birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + + await agent.setFeedViewPrefs('home', { hideReplies: true }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], + saved: ['at://bob.com/app.bsky.feed.generator/fake2'], + }, + adultContentEnabled: false, + contentLabels: { + impersonation: 'hide', + spam: 'ignore', + }, + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + + await agent.setFeedViewPrefs('home', { hideReplies: false }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], + saved: ['at://bob.com/app.bsky.feed.generator/fake2'], + }, + adultContentEnabled: false, + contentLabels: { + impersonation: 'hide', + spam: 'ignore', + }, + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + + await agent.setFeedViewPrefs('other', { hideReplies: true }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], + saved: ['at://bob.com/app.bsky.feed.generator/fake2'], + }, + adultContentEnabled: false, + contentLabels: { + impersonation: 'hide', + spam: 'ignore', + }, + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + other: { + hideReplies: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + + await agent.setThreadViewPrefs({ sort: 'random' }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], + saved: ['at://bob.com/app.bsky.feed.generator/fake2'], + }, + adultContentEnabled: false, + contentLabels: { + impersonation: 'hide', + spam: 'ignore', + }, + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + other: { + hideReplies: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'random', + prioritizeFollowedUsers: true, + }, + }) + + await agent.setThreadViewPrefs({ sort: 'oldest' }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], + saved: ['at://bob.com/app.bsky.feed.generator/fake2'], + }, + adultContentEnabled: false, + contentLabels: { + impersonation: 'hide', + spam: 'ignore', + }, + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + other: { + hideReplies: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, }) }) @@ -442,6 +767,34 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#personalDetailsPref', birthDate: '2021-09-11T18:05:42.556Z', }, + { + $type: 'app.bsky.actor.defs#feedViewPref', + feed: 'home', + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + { + $type: 'app.bsky.actor.defs#feedViewPref', + feed: 'home', + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + { + $type: 'app.bsky.actor.defs#threadViewPref', + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + { + $type: 'app.bsky.actor.defs#threadViewPref', + sort: 'newest', + prioritizeFollowedUsers: false, + }, ], }) await expect(agent.getPreferences()).resolves.toStrictEqual({ @@ -454,6 +807,19 @@ describe('agent', () => { nsfw: 'warn', }, birthDate: new Date('2021-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, }) await agent.setAdultContentEnabled(false) @@ -467,6 +833,19 @@ describe('agent', () => { nsfw: 'warn', }, birthDate: new Date('2021-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, }) await agent.setContentLabelPref('nsfw', 'hide') @@ -480,6 +859,19 @@ describe('agent', () => { nsfw: 'hide', }, birthDate: new Date('2021-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -493,6 +885,19 @@ describe('agent', () => { nsfw: 'hide', }, birthDate: new Date('2021-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, }) await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) @@ -506,29 +911,98 @@ describe('agent', () => { nsfw: 'hide', }, birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, }) - const res = await agent.app.bsky.actor.getPreferences() - await expect(res.data.preferences).toStrictEqual([ - { - $type: 'app.bsky.actor.defs#adultContentPref', - enabled: false, - }, - { - $type: 'app.bsky.actor.defs#contentLabelPref', - label: 'nsfw', - visibility: 'hide', - }, - { - $type: 'app.bsky.actor.defs#savedFeedsPref', + await agent.setFeedViewPrefs('home', { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }) + await agent.setThreadViewPrefs({ + sort: 'oldest', + prioritizeFollowedUsers: true, + }) + await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], }, - { - $type: 'app.bsky.actor.defs#personalDetailsPref', - birthDate: '2023-09-11T18:05:42.556Z', + adultContentEnabled: false, + contentLabels: { + nsfw: 'hide', }, - ]) + birthDate: new Date('2023-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + + const res = await agent.app.bsky.actor.getPreferences() + await expect(res.data.preferences.sort(byType)).toStrictEqual( + [ + { + $type: 'app.bsky.actor.defs#adultContentPref', + enabled: false, + }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'nsfw', + visibility: 'hide', + }, + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: ['at://bob.com/app.bsky.feed.generator/fake'], + saved: ['at://bob.com/app.bsky.feed.generator/fake'], + }, + { + $type: 'app.bsky.actor.defs#personalDetailsPref', + birthDate: '2023-09-11T18:05:42.556Z', + }, + + { + $type: 'app.bsky.actor.defs#feedViewPref', + feed: 'home', + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + { + $type: 'app.bsky.actor.defs#threadViewPref', + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + ].sort(byType), + ) }) }) }) + +const byType = (a, b) => a.$type.localeCompare(b.$type) diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 7ee31c74e62..48b1276adc8 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -3687,6 +3687,8 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', 'lex:app.bsky.actor.defs#personalDetailsPref', + 'lex:app.bsky.actor.defs#feedViewPref', + 'lex:app.bsky.actor.defs#threadViewPref', ], }, }, @@ -3743,6 +3745,53 @@ export const schemaDict = { }, }, }, + feedViewPref: { + type: 'object', + required: ['feed'], + properties: { + feed: { + type: 'string', + description: + 'The URI of the feed, or an identifier which describes the feed.', + }, + hideReplies: { + type: 'boolean', + description: 'Hide replies in the feed.', + }, + hideRepliesByUnfollowed: { + type: 'boolean', + description: + 'Hide replies in the feed if they are not by followed users.', + }, + hideRepliesByLikeCount: { + type: 'integer', + description: + 'Hide replies in the feed if they do not have this number of likes.', + }, + hideReposts: { + type: 'boolean', + description: 'Hide reposts in the feed.', + }, + hideQuotePosts: { + type: 'boolean', + description: 'Hide quote posts in the feed.', + }, + }, + }, + threadViewPref: { + type: 'object', + properties: { + sort: { + type: 'string', + description: 'Sorting mode.', + knownValues: ['oldest', 'newest', 'most-likes', 'random'], + }, + prioritizeFollowedUsers: { + type: 'boolean', + description: 'Show followed users at the top of all replies.', + }, + }, + }, }, }, AppBskyActorGetPreferences: { diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts index 4446c1f7a03..b24b04b34d7 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts @@ -109,6 +109,8 @@ export type Preferences = ( | ContentLabelPref | SavedFeedsPref | PersonalDetailsPref + | FeedViewPref + | ThreadViewPref | { $type: string; [k: string]: unknown } )[] @@ -182,3 +184,51 @@ export function isPersonalDetailsPref(v: unknown): v is PersonalDetailsPref { export function validatePersonalDetailsPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#personalDetailsPref', v) } + +export interface FeedViewPref { + /** The URI of the feed, or an identifier which describes the feed. */ + feed: string + /** Hide replies in the feed. */ + hideReplies?: boolean + /** Hide replies in the feed if they are not by followed users. */ + hideRepliesByUnfollowed?: boolean + /** Hide replies in the feed if they do not have this number of likes. */ + hideRepliesByLikeCount?: number + /** Hide reposts in the feed. */ + hideReposts?: boolean + /** Hide quote posts in the feed. */ + hideQuotePosts?: boolean + [k: string]: unknown +} + +export function isFeedViewPref(v: unknown): v is FeedViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#feedViewPref' + ) +} + +export function validateFeedViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#feedViewPref', v) +} + +export interface ThreadViewPref { + /** Sorting mode. */ + sort?: 'oldest' | 'newest' | 'most-likes' | 'random' | (string & {}) + /** Show followed users at the top of all replies. */ + prioritizeFollowedUsers?: boolean + [k: string]: unknown +} + +export function isThreadViewPref(v: unknown): v is ThreadViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#threadViewPref' + ) +} + +export function validateThreadViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#threadViewPref', v) +} diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 7ee31c74e62..48b1276adc8 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -3687,6 +3687,8 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', 'lex:app.bsky.actor.defs#personalDetailsPref', + 'lex:app.bsky.actor.defs#feedViewPref', + 'lex:app.bsky.actor.defs#threadViewPref', ], }, }, @@ -3743,6 +3745,53 @@ export const schemaDict = { }, }, }, + feedViewPref: { + type: 'object', + required: ['feed'], + properties: { + feed: { + type: 'string', + description: + 'The URI of the feed, or an identifier which describes the feed.', + }, + hideReplies: { + type: 'boolean', + description: 'Hide replies in the feed.', + }, + hideRepliesByUnfollowed: { + type: 'boolean', + description: + 'Hide replies in the feed if they are not by followed users.', + }, + hideRepliesByLikeCount: { + type: 'integer', + description: + 'Hide replies in the feed if they do not have this number of likes.', + }, + hideReposts: { + type: 'boolean', + description: 'Hide reposts in the feed.', + }, + hideQuotePosts: { + type: 'boolean', + description: 'Hide quote posts in the feed.', + }, + }, + }, + threadViewPref: { + type: 'object', + properties: { + sort: { + type: 'string', + description: 'Sorting mode.', + knownValues: ['oldest', 'newest', 'most-likes', 'random'], + }, + prioritizeFollowedUsers: { + type: 'boolean', + description: 'Show followed users at the top of all replies.', + }, + }, + }, }, }, AppBskyActorGetPreferences: { diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts index 4446c1f7a03..b24b04b34d7 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts @@ -109,6 +109,8 @@ export type Preferences = ( | ContentLabelPref | SavedFeedsPref | PersonalDetailsPref + | FeedViewPref + | ThreadViewPref | { $type: string; [k: string]: unknown } )[] @@ -182,3 +184,51 @@ export function isPersonalDetailsPref(v: unknown): v is PersonalDetailsPref { export function validatePersonalDetailsPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#personalDetailsPref', v) } + +export interface FeedViewPref { + /** The URI of the feed, or an identifier which describes the feed. */ + feed: string + /** Hide replies in the feed. */ + hideReplies?: boolean + /** Hide replies in the feed if they are not by followed users. */ + hideRepliesByUnfollowed?: boolean + /** Hide replies in the feed if they do not have this number of likes. */ + hideRepliesByLikeCount?: number + /** Hide reposts in the feed. */ + hideReposts?: boolean + /** Hide quote posts in the feed. */ + hideQuotePosts?: boolean + [k: string]: unknown +} + +export function isFeedViewPref(v: unknown): v is FeedViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#feedViewPref' + ) +} + +export function validateFeedViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#feedViewPref', v) +} + +export interface ThreadViewPref { + /** Sorting mode. */ + sort?: 'oldest' | 'newest' | 'most-likes' | 'random' | (string & {}) + /** Show followed users at the top of all replies. */ + prioritizeFollowedUsers?: boolean + [k: string]: unknown +} + +export function isThreadViewPref(v: unknown): v is ThreadViewPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#threadViewPref' + ) +} + +export function validateThreadViewPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#threadViewPref', v) +}