Skip to content

Commit

Permalink
Add savedFeedsPrefV2 and new methods (#2427)
Browse files Browse the repository at this point in the history
* Preview

* Double write v2 -> v1

* Add savedFeeds output

* Tightening up

* Revise and clean up APIs, update tests

* Codegen

* Enforce sort order

* Fix unrelated tests

* Reduce edits by using old naming

* Remove redundant test, fix title

* Add changeset

* Ensure unique constraints preserve insertion order if duplicates are found

* Adds addSavedFeeds as a convenience

* Use pluralized interfaces

* Update deprecation notices

* Update method naming

* Update deprecation comments

* Filter invalid types during migration

* Change uri of default timeline

* Fix typo
  • Loading branch information
estrattonbailey committed Apr 24, 2024
1 parent d9c1156 commit b9b7c58
Show file tree
Hide file tree
Showing 15 changed files with 1,647 additions and 3 deletions.
7 changes: 7 additions & 0 deletions .changeset/five-planes-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@atproto/api": patch
---

Introduces V2 of saved feeds preferences. V2 and v1 prefs are incompatible. v1
methods and preference objects are retained for backwards compatability, but are
considered deprecated. Developers should immediately migrate to v2 interfaces.
33 changes: 33 additions & 0 deletions lexicons/app/bsky/actor/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"#adultContentPref",
"#contentLabelPref",
"#savedFeedsPref",
"#savedFeedsPrefV2",
"#personalDetailsPref",
"#feedViewPref",
"#threadViewPref",
Expand Down Expand Up @@ -154,6 +155,38 @@
}
}
},
"savedFeed": {
"type": "object",
"required": ["id", "type", "value", "pinned"],
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"knownValues": ["feed", "list", "timeline"]
},
"value": {
"type": "string"
},
"pinned": {
"type": "boolean"
}
}
},
"savedFeedsPrefV2": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#savedFeed"
}
}
}
},
"savedFeedsPref": {
"type": "object",
"required": ["pinned", "saved"],
Expand Down
223 changes: 222 additions & 1 deletion packages/api/src/bsky-agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AtUri, ensureValidDid } from '@atproto/syntax'
import { TID } from '@atproto/common-web'
import { AtpAgent } from './agent'
import {
AppBskyFeedPost,
Expand All @@ -19,7 +20,12 @@ import {
ModerationPrefs,
} from './moderation/types'
import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels'
import { sanitizeMutedWordValue } from './util'
import {
sanitizeMutedWordValue,
validateSavedFeed,
savedFeedsToUriArrays,
getSavedFeedType,
} from './util'
import { interpretLabelValueDefinitions } from './moderation'

const FEED_VIEW_PREF_DEFAULTS = {
Expand Down Expand Up @@ -365,6 +371,8 @@ export class BskyAgent extends AtpAgent {
saved: undefined,
pinned: undefined,
},
// @ts-ignore populating below
savedFeeds: undefined,
feedViewPrefs: {
home: {
...FEED_VIEW_PREF_DEFAULTS,
Expand Down Expand Up @@ -412,6 +420,11 @@ export class BskyAgent extends AtpAgent {
labels: {},
})),
)
} else if (
AppBskyActorDefs.isSavedFeedsPrefV2(pref) &&
AppBskyActorDefs.validateSavedFeedsPrefV2(pref).success
) {
prefs.savedFeeds = pref.items
} else if (
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success
Expand Down Expand Up @@ -467,6 +480,75 @@ export class BskyAgent extends AtpAgent {
}
}

/*
* If `prefs.savedFeeds` is undefined, no `savedFeedsPrefV2` exists, which
* means we want to try to migrate if needed.
*
* If v1 prefs exist, they will be migrated to v2.
*
* If no v1 prefs exist, the user is either new, or could be old and has
* never edited their feeds.
*/
if (prefs.savedFeeds === undefined) {
const { saved, pinned } = prefs.feeds

if (saved && pinned) {
const uniqueMigratedSavedFeeds: Map<
string,
AppBskyActorDefs.SavedFeed
> = new Map()

// insert Following feed first
uniqueMigratedSavedFeeds.set('timeline', {
id: TID.nextStr(),
type: 'timeline',
value: 'following',
pinned: true,
})

// use pinned as source of truth for feed order
for (const uri of pinned) {
const type = getSavedFeedType(uri)
// only want supported types
if (type === 'unknown') continue
uniqueMigratedSavedFeeds.set(uri, {
id: TID.nextStr(),
type,
value: uri,
pinned: true,
})
}

for (const uri of saved) {
if (!uniqueMigratedSavedFeeds.has(uri)) {
const type = getSavedFeedType(uri)
// only want supported types
if (type === 'unknown') continue
uniqueMigratedSavedFeeds.set(uri, {
id: TID.nextStr(),
type,
value: uri,
pinned: false,
})
}
}

prefs.savedFeeds = Array.from(uniqueMigratedSavedFeeds.values())
} else {
prefs.savedFeeds = [
{
id: TID.nextStr(),
type: 'timeline',
value: 'following',
pinned: true,
},
]
}

// save to user preferences so this migration doesn't re-occur
await this.overwriteSavedFeeds(prefs.savedFeeds)
}

// apply the label prefs
for (const pref of labelPrefs) {
if (pref.labelerDid) {
Expand All @@ -491,34 +573,103 @@ export class BskyAgent extends AtpAgent {
return prefs
}

async overwriteSavedFeeds(savedFeeds: AppBskyActorDefs.SavedFeed[]) {
savedFeeds.forEach(validateSavedFeed)
const uniqueSavedFeeds = new Map<string, AppBskyActorDefs.SavedFeed>()
savedFeeds.forEach((feed) => {
// remove and re-insert to preserve order
if (uniqueSavedFeeds.has(feed.id)) {
uniqueSavedFeeds.delete(feed.id)
}
uniqueSavedFeeds.set(feed.id, feed)
})
return updateSavedFeedsV2Preferences(this, () =>
Array.from(uniqueSavedFeeds.values()),
)
}

async updateSavedFeeds(savedFeedsToUpdate: AppBskyActorDefs.SavedFeed[]) {
savedFeedsToUpdate.map(validateSavedFeed)
return updateSavedFeedsV2Preferences(this, (savedFeeds) => {
return savedFeeds.map((savedFeed) => {
const updatedVersion = savedFeedsToUpdate.find(
(updated) => savedFeed.id === updated.id,
)
if (updatedVersion) {
return {
...savedFeed,
// only update pinned
pinned: updatedVersion.pinned,
}
}
return savedFeed
})
})
}

async addSavedFeeds(
savedFeeds: Pick<AppBskyActorDefs.SavedFeed, 'type' | 'value' | 'pinned'>[],
) {
const toSave: AppBskyActorDefs.SavedFeed[] = savedFeeds.map((f) => ({
...f,
id: TID.nextStr(),
}))
toSave.forEach(validateSavedFeed)
return updateSavedFeedsV2Preferences(this, (savedFeeds) => [
...savedFeeds,
...toSave,
])
}

async removeSavedFeeds(ids: string[]) {
return updateSavedFeedsV2Preferences(this, (savedFeeds) => [
...savedFeeds.filter((feed) => !ids.find((id) => feed.id === id)),
])
}

/**
* @deprecated use `overwriteSavedFeeds`
*/
async setSavedFeeds(saved: string[], pinned: string[]) {
return updateFeedPreferences(this, () => ({
saved,
pinned,
}))
}

/**
* @deprecated use `addSavedFeeds`
*/
async addSavedFeed(v: string) {
return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({
saved: [...saved.filter((uri) => uri !== v), v],
pinned,
}))
}

/**
* @deprecated use `removeSavedFeeds`
*/
async removeSavedFeed(v: string) {
return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({
saved: saved.filter((uri) => uri !== v),
pinned: pinned.filter((uri) => uri !== v),
}))
}

/**
* @deprecated use `addSavedFeeds` or `updateSavedFeeds`
*/
async addPinnedFeed(v: string) {
return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({
saved: [...saved.filter((uri) => uri !== v), v],
pinned: [...pinned.filter((uri) => uri !== v), v],
}))
}

/**
* @deprecated use `updateSavedFeeds` or `removeSavedFeeds`
*/
async removePinnedFeed(v: string) {
return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({
saved,
Expand Down Expand Up @@ -945,6 +1096,76 @@ async function updateFeedPreferences(
return res
}

async function updateSavedFeedsV2Preferences(
agent: BskyAgent,
cb: (
savedFeedsPref: AppBskyActorDefs.SavedFeed[],
) => AppBskyActorDefs.SavedFeed[],
): Promise<AppBskyActorDefs.SavedFeed[]> {
let maybeMutatedSavedFeeds: AppBskyActorDefs.SavedFeed[] = []

await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => {
let existingV2Pref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isSavedFeedsPrefV2(pref) &&
AppBskyActorDefs.validateSavedFeedsPrefV2(pref).success,
) as AppBskyActorDefs.SavedFeedsPrefV2 | undefined
let existingV1Pref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success,
) as AppBskyActorDefs.SavedFeedsPref | undefined

if (existingV2Pref) {
maybeMutatedSavedFeeds = cb(existingV2Pref.items)
existingV2Pref = {
...existingV2Pref,
items: maybeMutatedSavedFeeds,
}
} else {
maybeMutatedSavedFeeds = cb([])
existingV2Pref = {
$type: 'app.bsky.actor.defs#savedFeedsPrefV2',
items: maybeMutatedSavedFeeds,
}
}

// enforce ordering, pinned then saved
const pinned = existingV2Pref.items.filter((i) => i.pinned)
const saved = existingV2Pref.items.filter((i) => !i.pinned)
existingV2Pref.items = pinned.concat(saved)

let updatedPrefs = prefs
.filter((pref) => !AppBskyActorDefs.isSavedFeedsPrefV2(pref))
.concat(existingV2Pref)

/*
* If there's a v2 pref present, it means this account was migrated from v1
* to v2. During the transition period, we double write v2 prefs back to
* v1, but NOT the other way around.
*/
if (existingV1Pref) {
const { saved, pinned } = existingV1Pref
const v2Compat = savedFeedsToUriArrays(
// v1 only supports feeds and lists
existingV2Pref.items.filter((i) => ['feed', 'list'].includes(i.type)),
)
existingV1Pref = {
...existingV1Pref,
saved: Array.from(new Set([...saved, ...v2Compat.saved])),
pinned: Array.from(new Set([...pinned, ...v2Compat.pinned])),
}
updatedPrefs = updatedPrefs
.filter((pref) => !AppBskyActorDefs.isSavedFeedsPref(pref))
.concat(existingV1Pref)
}

return updatedPrefs
})

return maybeMutatedSavedFeeds
}

/**
* Helper to transform the legacy content preferences.
*/
Expand Down
33 changes: 33 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3822,6 +3822,7 @@ export const schemaDict = {
'lex:app.bsky.actor.defs#adultContentPref',
'lex:app.bsky.actor.defs#contentLabelPref',
'lex:app.bsky.actor.defs#savedFeedsPref',
'lex:app.bsky.actor.defs#savedFeedsPrefV2',
'lex:app.bsky.actor.defs#personalDetailsPref',
'lex:app.bsky.actor.defs#feedViewPref',
'lex:app.bsky.actor.defs#threadViewPref',
Expand Down Expand Up @@ -3860,6 +3861,38 @@ export const schemaDict = {
},
},
},
savedFeed: {
type: 'object',
required: ['id', 'type', 'value', 'pinned'],
properties: {
id: {
type: 'string',
},
type: {
type: 'string',
knownValues: ['feed', 'list', 'timeline'],
},
value: {
type: 'string',
},
pinned: {
type: 'boolean',
},
},
},
savedFeedsPrefV2: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#savedFeed',
},
},
},
},
savedFeedsPref: {
type: 'object',
required: ['pinned', 'saved'],
Expand Down

0 comments on commit b9b7c58

Please sign in to comment.