UNPKG

@atproto/api

Version:

Client library for atproto and Bluesky

1,599 lines (1,369 loc) 46 kB
import AwaitLock from 'await-lock' import { TID, retry } from '@atproto/common-web' import { AtUri, ensureValidDid } from '@atproto/syntax' import { FetchHandler, FetchHandlerOptions, XrpcClient, buildFetchHandler, } from '@atproto/xrpc' import { AppBskyActorDefs, AppBskyActorProfile, AppBskyFeedPost, AppBskyLabelerDefs, AppNS, ChatNS, ComAtprotoRepoPutRecord, ComNS, ToolsNS, } from './client/index' import { schemas } from './client/lexicons' import { MutedWord, Nux } from './client/types/app/bsky/actor/defs' import { $Typed, Un$Typed } from './client/util' import { BSKY_LABELER_DID } from './const' import { interpretLabelValueDefinitions } from './moderation' import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' import { InterpretedLabelValueDefinition, LabelPreference, ModerationPrefs, } from './moderation/types' import * as predicate from './predicate' import { SessionManager } from './session-manager' import { AtpAgentGlobalOpts, AtprotoProxy, AtprotoServiceType, BskyFeedViewPreference, BskyInterestsPreference, BskyPreferences, BskyThreadViewPreference, asAtprotoProxy, asDid, isDid, } from './types' import { getSavedFeedType, sanitizeMutedWordValue, savedFeedsToUriArrays, validateNux, validateSavedFeed, } from './util' const FEED_VIEW_PREF_DEFAULTS = { hideReplies: false, hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, } const THREAD_VIEW_PREF_DEFAULTS = { sort: 'hotness', } export type { FetchHandler } /** * An {@link Agent} is an {@link AtpBaseClient} with the following * additional features: * - AT Protocol labelers configuration utilities * - AT Protocol proxy configuration utilities * - Cloning utilities * - `app.bsky` syntactic sugar * - `com.atproto` syntactic sugar */ export class Agent extends XrpcClient { //#region Static configuration /** * The labelers to be used across all requests with the takedown capability */ static appLabelers: readonly string[] = [BSKY_LABELER_DID] /** * Configures the Agent (or its sub classes) globally. */ static configure(opts: AtpAgentGlobalOpts) { if (opts.appLabelers) { this.appLabelers = opts.appLabelers.map(asDid) // Validate & copy } } //#endregion com = new ComNS(this) app = new AppNS(this) chat = new ChatNS(this) tools = new ToolsNS(this) /** @deprecated use `this` instead */ get xrpc(): XrpcClient { return this } readonly sessionManager: SessionManager constructor(options: SessionManager | FetchHandler | FetchHandlerOptions) { const sessionManager: SessionManager = typeof options === 'object' && 'fetchHandler' in options ? options : { did: undefined, fetchHandler: buildFetchHandler(options), } super((url, init) => { const headers = new Headers(init?.headers) if (this.proxy && !headers.has('atproto-proxy')) { headers.set('atproto-proxy', this.proxy) } // Merge the labelers header of this particular request with the app & // instance labelers. headers.set( 'atproto-accept-labelers', [ ...this.appLabelers.map((l) => `${l};redact`), ...this.labelers, headers.get('atproto-accept-labelers')?.trim(), ] .filter(Boolean) .join(', '), ) return this.sessionManager.fetchHandler(url, { ...init, headers }) }, schemas) this.sessionManager = sessionManager } //#region Cloning utilities clone(): Agent { return this.copyInto(new Agent(this.sessionManager)) } copyInto<T extends Agent>(inst: T): T { inst.configureLabelers(this.labelers) inst.configureProxy(this.proxy ?? null) inst.clearHeaders() for (const [key, value] of this.headers) inst.setHeader(key, value) return inst } withProxy(serviceType: AtprotoServiceType, did: string) { const inst = this.clone() inst.configureProxy(`${asDid(did)}#${serviceType}`) return inst as ReturnType<this['clone']> } //#endregion //#region ATPROTO labelers configuration utilities /** * The labelers statically configured on the class of the current instance. */ get appLabelers() { return (this.constructor as typeof Agent).appLabelers } labelers: readonly string[] = [] configureLabelers(labelerDids: readonly string[]) { this.labelers = labelerDids.map(asDid) // Validate & copy } /** @deprecated use {@link configureLabelers} instead */ configureLabelersHeader(labelerDids: readonly string[]) { // Filtering non-did values for backwards compatibility this.configureLabelers(labelerDids.filter(isDid)) } //#endregion //#region ATPROTO proxy configuration utilities proxy?: AtprotoProxy configureProxy(value: AtprotoProxy | null) { if (value === null) this.proxy = undefined else this.proxy = asAtprotoProxy(value) } /** @deprecated use {@link configureProxy} instead */ configureProxyHeader(serviceType: AtprotoServiceType, did: string) { // Ignoring non-did values for backwards compatibility if (isDid(did)) this.configureProxy(`${did}#${serviceType}`) } //#endregion //#region Session management /** * Get the authenticated user's DID, if any. */ get did() { return this.sessionManager.did } /** @deprecated Use {@link Agent.assertDid} instead */ get accountDid() { return this.assertDid } /** * Get the authenticated user's DID, or throw an error if not authenticated. */ get assertDid(): string { this.assertAuthenticated() return this.did } /** * Assert that the user is authenticated. */ public assertAuthenticated(): asserts this is { did: string } { if (!this.did) throw new Error('Not logged in') } //#endregion /** @deprecated use "this" instead */ get api() { return this } //#region "com.atproto" lexicon short hand methods /** * Upload a binary blob to the server */ uploadBlob: typeof this.com.atproto.repo.uploadBlob = (data, opts) => this.com.atproto.repo.uploadBlob(data, opts) /** * Resolve a handle to a DID */ resolveHandle: typeof this.com.atproto.identity.resolveHandle = ( params, opts, ) => this.com.atproto.identity.resolveHandle(params, opts) /** * Change the user's handle */ updateHandle: typeof this.com.atproto.identity.updateHandle = (data, opts) => this.com.atproto.identity.updateHandle(data, opts) /** * Create a moderation report */ createModerationReport: typeof this.com.atproto.moderation.createReport = ( data, opts, ) => this.com.atproto.moderation.createReport(data, opts) //#endregion //#region "app.bsky" lexicon short hand methods getTimeline: typeof this.app.bsky.feed.getTimeline = (params, opts) => this.app.bsky.feed.getTimeline(params, opts) getAuthorFeed: typeof this.app.bsky.feed.getAuthorFeed = (params, opts) => this.app.bsky.feed.getAuthorFeed(params, opts) getActorLikes: typeof this.app.bsky.feed.getActorLikes = (params, opts) => this.app.bsky.feed.getActorLikes(params, opts) getPostThread: typeof this.app.bsky.feed.getPostThread = (params, opts) => this.app.bsky.feed.getPostThread(params, opts) getPost: typeof this.app.bsky.feed.post.get = (params) => this.app.bsky.feed.post.get(params) getPosts: typeof this.app.bsky.feed.getPosts = (params, opts) => this.app.bsky.feed.getPosts(params, opts) getLikes: typeof this.app.bsky.feed.getLikes = (params, opts) => this.app.bsky.feed.getLikes(params, opts) getRepostedBy: typeof this.app.bsky.feed.getRepostedBy = (params, opts) => this.app.bsky.feed.getRepostedBy(params, opts) getFollows: typeof this.app.bsky.graph.getFollows = (params, opts) => this.app.bsky.graph.getFollows(params, opts) getFollowers: typeof this.app.bsky.graph.getFollowers = (params, opts) => this.app.bsky.graph.getFollowers(params, opts) getProfile: typeof this.app.bsky.actor.getProfile = (params, opts) => this.app.bsky.actor.getProfile(params, opts) getProfiles: typeof this.app.bsky.actor.getProfiles = (params, opts) => this.app.bsky.actor.getProfiles(params, opts) getSuggestions: typeof this.app.bsky.actor.getSuggestions = (params, opts) => this.app.bsky.actor.getSuggestions(params, opts) searchActors: typeof this.app.bsky.actor.searchActors = (params, opts) => this.app.bsky.actor.searchActors(params, opts) searchActorsTypeahead: typeof this.app.bsky.actor.searchActorsTypeahead = ( params, opts, ) => this.app.bsky.actor.searchActorsTypeahead(params, opts) listNotifications: typeof this.app.bsky.notification.listNotifications = ( params, opts, ) => this.app.bsky.notification.listNotifications(params, opts) countUnreadNotifications: typeof this.app.bsky.notification.getUnreadCount = ( params, opts, ) => this.app.bsky.notification.getUnreadCount(params, opts) getLabelers: typeof this.app.bsky.labeler.getServices = (params, opts) => this.app.bsky.labeler.getServices(params, opts) async getLabelDefinitions( prefs: BskyPreferences | ModerationPrefs | string[], ): Promise<Record<string, InterpretedLabelValueDefinition[]>> { // collect the labeler dids const dids: string[] = [...this.appLabelers] if (isBskyPrefs(prefs)) { dids.push(...prefs.moderationPrefs.labelers.map((l) => l.did)) } else if (isModPrefs(prefs)) { dids.push(...prefs.labelers.map((l) => l.did)) } else { dids.push(...prefs) } // fetch their definitions const labelers = await this.getLabelers({ dids, detailed: true, }) // assemble a map of labeler dids to the interpreted label value definitions const labelDefs = {} if (labelers.data) { for (const labeler of labelers.data .views as AppBskyLabelerDefs.LabelerViewDetailed[]) { labelDefs[labeler.creator.did] = interpretLabelValueDefinitions(labeler) } } return labelDefs } async post( record: Partial<AppBskyFeedPost.Record> & Omit<AppBskyFeedPost.Record, 'createdAt'>, ) { record.createdAt ||= new Date().toISOString() return this.app.bsky.feed.post.create( { repo: this.accountDid }, record as AppBskyFeedPost.Record, ) } async deletePost(postUri: string) { this.assertAuthenticated() const postUrip = new AtUri(postUri) return this.app.bsky.feed.post.delete({ repo: postUrip.hostname, rkey: postUrip.rkey, }) } async like(uri: string, cid: string, via?: { uri: string; cid: string }) { return this.app.bsky.feed.like.create( { repo: this.accountDid }, { subject: { uri, cid }, createdAt: new Date().toISOString(), via, }, ) } async deleteLike(likeUri: string) { this.assertAuthenticated() const likeUrip = new AtUri(likeUri) return this.app.bsky.feed.like.delete({ repo: likeUrip.hostname, rkey: likeUrip.rkey, }) } async repost(uri: string, cid: string, via?: { uri: string; cid: string }) { return this.app.bsky.feed.repost.create( { repo: this.accountDid }, { subject: { uri, cid }, createdAt: new Date().toISOString(), via, }, ) } async deleteRepost(repostUri: string) { this.assertAuthenticated() const repostUrip = new AtUri(repostUri) return this.app.bsky.feed.repost.delete({ repo: repostUrip.hostname, rkey: repostUrip.rkey, }) } async follow(subjectDid: string, via?: { uri: string; cid: string }) { return this.app.bsky.graph.follow.create( { repo: this.accountDid }, { subject: subjectDid, createdAt: new Date().toISOString(), via, }, ) } async deleteFollow(followUri: string) { this.assertAuthenticated() const followUrip = new AtUri(followUri) return this.app.bsky.graph.follow.delete({ repo: followUrip.hostname, rkey: followUrip.rkey, }) } /** * @note: Using this method will reset the whole profile record if it * previously contained invalid values (wrt to the profile lexicon). */ async upsertProfile( updateFn: ( existing: AppBskyActorProfile.Record | undefined, ) => | Un$Typed<AppBskyActorProfile.Record> | Promise<Un$Typed<AppBskyActorProfile.Record>>, ): Promise<void> { const upsert = async () => { const repo = this.assertDid const collection = 'app.bsky.actor.profile' const existing = await this.com.atproto.repo .getRecord({ repo, collection, rkey: 'self' }) .catch((_) => undefined) const existingRecord: AppBskyActorProfile.Record | undefined = existing && predicate.isValidProfile(existing.data.value) ? existing.data.value : undefined // run the update const updated = await updateFn(existingRecord) // validate the value returned by the update function const validation = AppBskyActorProfile.validateRecord({ $type: collection, ...updated, }) if (!validation.success) { throw validation.error } await this.com.atproto.repo.putRecord({ repo, collection, rkey: 'self', record: validation.value, swapRecord: existing?.data.cid || null, }) } return retry(upsert, { maxRetries: 5, retryable: (e) => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, }) } async mute(actor: string) { return this.app.bsky.graph.muteActor({ actor }) } async unmute(actor: string) { return this.app.bsky.graph.unmuteActor({ actor }) } async muteModList(uri: string) { return this.app.bsky.graph.muteActorList({ list: uri }) } async unmuteModList(uri: string) { return this.app.bsky.graph.unmuteActorList({ list: uri }) } async blockModList(uri: string) { return this.app.bsky.graph.listblock.create( { repo: this.accountDid }, { subject: uri, createdAt: new Date().toISOString(), }, ) } async unblockModList(uri: string) { const repo = this.accountDid const listInfo = await this.app.bsky.graph.getList({ list: uri, limit: 1, }) const blocked = listInfo.data.list.viewer?.blocked if (blocked) { const { rkey } = new AtUri(blocked) return this.app.bsky.graph.listblock.delete({ repo, rkey, }) } } async updateSeenNotifications(seenAt = new Date().toISOString()) { return this.app.bsky.notification.updateSeen({ seenAt }) } async getPreferences(): Promise<BskyPreferences> { const prefs: BskyPreferences = { feeds: { saved: undefined, pinned: undefined, }, // @ts-ignore populating below savedFeeds: undefined, feedViewPrefs: { home: { ...FEED_VIEW_PREF_DEFAULTS, }, }, threadViewPrefs: { ...THREAD_VIEW_PREF_DEFAULTS }, moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS }, labelers: this.appLabelers.map((did) => ({ did, labels: {}, })), mutedWords: [], hiddenPosts: [], }, birthDate: undefined, interests: { tags: [], }, bskyAppState: { queuedNudges: [], activeProgressGuide: undefined, nuxs: [], }, postInteractionSettings: { threadgateAllowRules: undefined, postgateEmbeddingRules: undefined, }, verificationPrefs: { hideBadges: false, }, } const res = await this.app.bsky.actor.getPreferences({}) const labelPrefs: AppBskyActorDefs.ContentLabelPref[] = [] for (const pref of res.data.preferences) { if (predicate.isValidAdultContentPref(pref)) { // adult content preferences prefs.moderationPrefs.adultContentEnabled = pref.enabled } else if (predicate.isValidContentLabelPref(pref)) { // content label preference const adjustedPref = adjustLegacyContentLabelPref(pref) labelPrefs.push(adjustedPref) } else if (predicate.isValidLabelersPref(pref)) { // labelers preferences prefs.moderationPrefs.labelers = this.appLabelers .map((did: string) => ({ did, labels: {} })) .concat( pref.labelers.map((labeler) => ({ ...labeler, labels: {}, })), ) } else if (predicate.isValidSavedFeedsPrefV2(pref)) { prefs.savedFeeds = pref.items } else if (predicate.isValidSavedFeedsPref(pref)) { // saved and pinned feeds prefs.feeds.saved = pref.saved prefs.feeds.pinned = pref.pinned } else if (predicate.isValidPersonalDetailsPref(pref)) { // birth date (irl) if (pref.birthDate) { prefs.birthDate = new Date(pref.birthDate) } } else if (predicate.isValidDeclaredAgePref(pref)) { const { $type: _, ...declaredAgePref } = pref prefs.declaredAge = declaredAgePref } else if (predicate.isValidFeedViewPref(pref)) { // feed view preferences const { $type: _, feed, ...v } = pref prefs.feedViewPrefs[feed] = { ...FEED_VIEW_PREF_DEFAULTS, ...v } } else if (predicate.isValidThreadViewPref(pref)) { // thread view preferences const { $type: _, ...v } = pref prefs.threadViewPrefs = { ...prefs.threadViewPrefs, ...v } } else if (predicate.isValidInterestsPref(pref)) { const { $type: _, ...v } = pref prefs.interests = { ...prefs.interests, ...v } } else if (predicate.isValidMutedWordsPref(pref)) { prefs.moderationPrefs.mutedWords = pref.items if (prefs.moderationPrefs.mutedWords.length) { prefs.moderationPrefs.mutedWords = prefs.moderationPrefs.mutedWords.map((word) => { word.actorTarget = word.actorTarget || 'all' return word }) } } else if (predicate.isValidHiddenPostsPref(pref)) { prefs.moderationPrefs.hiddenPosts = pref.items } else if (predicate.isValidBskyAppStatePref(pref)) { prefs.bskyAppState.queuedNudges = pref.queuedNudges || [] prefs.bskyAppState.activeProgressGuide = pref.activeProgressGuide prefs.bskyAppState.nuxs = pref.nuxs || [] } else if (predicate.isValidPostInteractionSettingsPref(pref)) { prefs.postInteractionSettings.threadgateAllowRules = pref.threadgateAllowRules prefs.postInteractionSettings.postgateEmbeddingRules = pref.postgateEmbeddingRules } else if (predicate.isValidVerificationPrefs(pref)) { prefs.verificationPrefs = { hideBadges: pref.hideBadges, } } } /* * 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 == null) { 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) { const labeler = prefs.moderationPrefs.labelers.find( (labeler) => labeler.did === pref.labelerDid, ) if (!labeler) continue labeler.labels[pref.label] = pref.visibility as LabelPreference } else { prefs.moderationPrefs.labels[pref.label] = pref.visibility as LabelPreference } } prefs.moderationPrefs.labels = remapLegacyLabels( prefs.moderationPrefs.labels, ) // automatically configure the client this.configureLabelers(prefsArrayToLabelerDids(res.data.preferences)) 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 this.updateSavedFeedsV2Preferences(() => Array.from(uniqueSavedFeeds.values()), ) } async updateSavedFeeds(savedFeedsToUpdate: AppBskyActorDefs.SavedFeed[]) { savedFeedsToUpdate.map(validateSavedFeed) return this.updateSavedFeedsV2Preferences((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 this.updateSavedFeedsV2Preferences((savedFeeds) => [ ...savedFeeds, ...toSave, ]) } async removeSavedFeeds(ids: string[]) { return this.updateSavedFeedsV2Preferences((savedFeeds) => [ ...savedFeeds.filter((feed) => !ids.find((id) => feed.id === id)), ]) } /** * @deprecated use `overwriteSavedFeeds` */ async setSavedFeeds(saved: string[], pinned: string[]) { return this.updateFeedPreferences(() => ({ saved, pinned, })) } /** * @deprecated use `addSavedFeeds` */ async addSavedFeed(v: string) { return this.updateFeedPreferences((saved: string[], pinned: string[]) => ({ saved: [...saved.filter((uri) => uri !== v), v], pinned, })) } /** * @deprecated use `removeSavedFeeds` */ async removeSavedFeed(v: string) { return this.updateFeedPreferences((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 this.updateFeedPreferences((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 this.updateFeedPreferences((saved: string[], pinned: string[]) => ({ saved, pinned: pinned.filter((uri) => uri !== v), })) } async setAdultContentEnabled(v: boolean) { await this.updatePreferences((prefs) => { const adultContentPref = prefs.findLast( predicate.isValidAdultContentPref, ) || { $type: 'app.bsky.actor.defs#adultContentPref', enabled: v, } adultContentPref.enabled = v return prefs .filter((pref) => !AppBskyActorDefs.isAdultContentPref(pref)) .concat(adultContentPref) }) } async setContentLabelPref( key: string, value: LabelPreference, labelerDid?: string, ) { if (labelerDid) { ensureValidDid(labelerDid) } await this.updatePreferences((prefs) => { const labelPref = prefs .filter(predicate.isValidContentLabelPref) .findLast( (pref) => pref.label === key && pref.labelerDid === labelerDid, ) || { $type: 'app.bsky.actor.defs#contentLabelPref', label: key, labelerDid, visibility: value, } labelPref.visibility = value let legacyLabelPref: $Typed<AppBskyActorDefs.ContentLabelPref> | undefined if (AppBskyActorDefs.isContentLabelPref(labelPref)) { // is global if (!labelPref.labelerDid) { const legacyLabelValue = { 'graphic-media': 'gore', porn: 'nsfw', sexual: 'suggestive', // Protect against using toString, hasOwnProperty, etc. as a label: __proto__: null, }[labelPref.label] // if it's a legacy label, double-write the legacy label if (legacyLabelValue) { legacyLabelPref = prefs .filter(predicate.isValidContentLabelPref) .findLast( (pref) => pref.label === legacyLabelValue && pref.labelerDid === undefined, ) || { $type: 'app.bsky.actor.defs#contentLabelPref', label: legacyLabelValue, labelerDid: undefined, visibility: value, } legacyLabelPref!.visibility = value } } } return prefs .filter( (pref) => !AppBskyActorDefs.isContentLabelPref(pref) || !(pref.label === key && pref.labelerDid === labelerDid), ) .concat(labelPref) .filter((pref) => { if (!legacyLabelPref) return true return ( !AppBskyActorDefs.isContentLabelPref(pref) || !( pref.label === legacyLabelPref.label && pref.labelerDid === undefined ) ) }) .concat(legacyLabelPref ? [legacyLabelPref] : []) }) } async addLabeler(did: string) { const prefs = await this.updatePreferences((prefs) => { const labelersPref = prefs.findLast(predicate.isValidLabelersPref) || { $type: 'app.bsky.actor.defs#labelersPref', labelers: [], } if (!labelersPref.labelers.some((labeler) => labeler.did === did)) { labelersPref.labelers.push({ did }) } return prefs .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref)) .concat(labelersPref) }) // automatically configure the client this.configureLabelers(prefsArrayToLabelerDids(prefs)) } async removeLabeler(did: string) { const prefs = await this.updatePreferences((prefs) => { const labelersPref = prefs.findLast(predicate.isValidLabelersPref) || { $type: 'app.bsky.actor.defs#labelersPref', labelers: [], } labelersPref.labelers = labelersPref.labelers.filter((l) => l.did !== did) return prefs .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref)) .concat(labelersPref) }) // automatically configure the client this.configureLabelers(prefsArrayToLabelerDids(prefs)) } async setPersonalDetails({ birthDate, }: { birthDate: string | Date | undefined }) { await this.updatePreferences((prefs) => { const personalDetailsPref = prefs.findLast( predicate.isValidPersonalDetailsPref, ) || { $type: 'app.bsky.actor.defs#personalDetailsPref', } personalDetailsPref.birthDate = birthDate instanceof Date ? birthDate.toISOString() : birthDate return prefs .filter((pref) => !AppBskyActorDefs.isPersonalDetailsPref(pref)) .concat(personalDetailsPref) }) } async setFeedViewPrefs(feed: string, pref: Partial<BskyFeedViewPreference>) { await this.updatePreferences((prefs) => { const existing = prefs .filter(predicate.isValidFeedViewPref) .findLast((pref) => pref.feed === feed) return prefs .filter((p) => !AppBskyActorDefs.isFeedViewPref(p) || p.feed !== feed) .concat({ ...existing, ...pref, $type: 'app.bsky.actor.defs#feedViewPref', feed, }) }) } async setThreadViewPrefs(pref: Partial<BskyThreadViewPreference>) { await this.updatePreferences((prefs) => { const existing = prefs.findLast(predicate.isValidThreadViewPref) return prefs .filter((p) => !AppBskyActorDefs.isThreadViewPref(p)) .concat({ ...existing, ...pref, $type: 'app.bsky.actor.defs#threadViewPref', }) }) } async setInterestsPref(pref: Partial<BskyInterestsPreference>) { await this.updatePreferences((prefs) => { const existing = prefs.findLast(predicate.isValidInterestsPref) return prefs .filter((p) => !AppBskyActorDefs.isInterestsPref(p)) .concat({ ...existing, ...pref, $type: 'app.bsky.actor.defs#interestsPref', }) }) } /** * Add a muted word to user preferences. */ async addMutedWord( mutedWord: Pick< MutedWord, 'value' | 'targets' | 'actorTarget' | 'expiresAt' >, ) { const sanitizedValue = sanitizeMutedWordValue(mutedWord.value) if (!sanitizedValue) return await this.updatePreferences((prefs) => { let mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref) const newMutedWord: AppBskyActorDefs.MutedWord = { id: TID.nextStr(), value: sanitizedValue, targets: mutedWord.targets || [], actorTarget: mutedWord.actorTarget || 'all', expiresAt: mutedWord.expiresAt || undefined, } if (mutedWordsPref) { mutedWordsPref.items.push(newMutedWord) /** * Migrate any old muted words that don't have an id */ mutedWordsPref.items = migrateLegacyMutedWordsItems( mutedWordsPref.items, ) } else { // if the pref doesn't exist, create it mutedWordsPref = { $type: 'app.bsky.actor.defs#mutedWordsPref', items: [newMutedWord], } } return prefs .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) .concat(mutedWordsPref) }) } /** * Convenience method to add muted words to user preferences */ async addMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) { await Promise.all(newMutedWords.map((word) => this.addMutedWord(word))) } /** * @deprecated use `addMutedWords` or `addMutedWord` instead */ async upsertMutedWords( mutedWords: Pick< MutedWord, 'value' | 'targets' | 'actorTarget' | 'expiresAt' >[], ) { await this.addMutedWords(mutedWords) } /** * Update a muted word in user preferences. */ async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { await this.updatePreferences((prefs) => { const mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref) if (mutedWordsPref) { mutedWordsPref.items = mutedWordsPref.items.map((existingItem) => { const match = matchMutedWord(existingItem, mutedWord) if (match) { const updated = { ...existingItem, ...mutedWord, } return { id: existingItem.id || TID.nextStr(), value: sanitizeMutedWordValue(updated.value) || existingItem.value, targets: updated.targets || [], actorTarget: updated.actorTarget || 'all', expiresAt: updated.expiresAt || undefined, } } else { return existingItem } }) /** * Migrate any old muted words that don't have an id */ mutedWordsPref.items = migrateLegacyMutedWordsItems( mutedWordsPref.items, ) return prefs .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) .concat(mutedWordsPref) } return prefs }) } /** * Remove a muted word from user preferences. */ async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { await this.updatePreferences((prefs) => { const mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref) if (mutedWordsPref) { for (let i = 0; i < mutedWordsPref.items.length; i++) { const match = matchMutedWord(mutedWordsPref.items[i], mutedWord) if (match) { mutedWordsPref.items.splice(i, 1) break } } /** * Migrate any old muted words that don't have an id */ mutedWordsPref.items = migrateLegacyMutedWordsItems( mutedWordsPref.items, ) return prefs .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) .concat(mutedWordsPref) } return prefs }) } /** * Convenience method to remove muted words from user preferences */ async removeMutedWords(mutedWords: AppBskyActorDefs.MutedWord[]) { await Promise.all(mutedWords.map((word) => this.removeMutedWord(word))) } async hidePost(postUri: string) { await this.updateHiddenPost(postUri, 'hide') } async unhidePost(postUri: string) { await this.updateHiddenPost(postUri, 'unhide') } async bskyAppQueueNudges(nudges: string | string[]) { await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || { $type: 'app.bsky.actor.defs#bskyAppStatePref', } pref.queuedNudges = (pref.queuedNudges || []).concat(nudges) return prefs .filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p)) .concat(pref) }) } async bskyAppDismissNudges(nudges: string | string[]) { await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || { $type: 'app.bsky.actor.defs#bskyAppStatePref', } nudges = Array.isArray(nudges) ? nudges : [nudges] pref.queuedNudges = (pref.queuedNudges || []).filter( (nudge) => !nudges.includes(nudge), ) return prefs .filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p)) .concat(pref) }) } async bskyAppSetActiveProgressGuide( guide: AppBskyActorDefs.BskyAppProgressGuide | undefined, ) { if (guide) { const result = AppBskyActorDefs.validateBskyAppProgressGuide(guide) if (!result.success) throw result.error } await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || { $type: 'app.bsky.actor.defs#bskyAppStatePref', } pref.activeProgressGuide = guide return prefs .filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p)) .concat(pref) }) } /** * Insert or update a NUX in user prefs */ async bskyAppUpsertNux(nux: Nux) { validateNux(nux) await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || { $type: 'app.bsky.actor.defs#bskyAppStatePref', } pref.nuxs = pref.nuxs || [] const existing = pref.nuxs?.find((n) => { return n.id === nux.id }) let next: AppBskyActorDefs.Nux if (existing) { next = { id: existing.id, completed: nux.completed, data: nux.data, expiresAt: nux.expiresAt, } } else { next = nux } // remove duplicates and append pref.nuxs = pref.nuxs.filter((n) => n.id !== nux.id).concat(next) return prefs .filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p)) .concat(pref) }) } /** * Removes NUXs from user preferences. */ async bskyAppRemoveNuxs(ids: string[]) { await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || { $type: 'app.bsky.actor.defs#bskyAppStatePref', } pref.nuxs = (pref.nuxs || []).filter((nux) => !ids.includes(nux.id)) return prefs .filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p)) .concat(pref) }) } async setPostInteractionSettings( settings: AppBskyActorDefs.PostInteractionSettingsPref, ) { const result = AppBskyActorDefs.validatePostInteractionSettingsPref(settings) // Fool-proofing (should not be needed because of type safety) if (!result.success) throw result.error await this.updatePreferences((prefs) => { const pref = prefs.findLast( predicate.isValidPostInteractionSettingsPref, ) || { $type: 'app.bsky.actor.defs#postInteractionSettingsPref', } /** * Matches handling of `threadgate.allow` where `undefined` means "everyone" */ pref.threadgateAllowRules = settings.threadgateAllowRules pref.postgateEmbeddingRules = settings.postgateEmbeddingRules return prefs .filter((p) => !AppBskyActorDefs.isPostInteractionSettingsPref(p)) .concat(pref) }) } async setVerificationPrefs(settings: AppBskyActorDefs.VerificationPrefs) { const result = AppBskyActorDefs.validateVerificationPrefs(settings) // Fool-proofing (should not be needed because of type safety) if (!result.success) throw result.error await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidVerificationPrefs) || { $type: 'app.bsky.actor.defs#verificationPrefs', hideBadges: false, } pref.hideBadges = settings.hideBadges return prefs .filter((p) => !AppBskyActorDefs.isVerificationPrefs(p)) .concat(pref) }) } //- Private methods #prefsLock = new AwaitLock() /** * This function updates the preferences of a user and allows for a callback function to be executed * before the update. * @param cb - cb is a callback function that takes in a single parameter of type * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to * update the preferences of the user. The function is called with the current preferences as an * argument and if the callback returns false, the preferences are not updated. */ private async updatePreferences( cb: ( prefs: AppBskyActorDefs.Preferences, ) => AppBskyActorDefs.Preferences | false, ) { try { await this.#prefsLock.acquireAsync() const res = await this.app.bsky.actor.getPreferences({}) const newPrefs = cb(res.data.preferences) if (newPrefs === false) { return res.data.preferences } await this.app.bsky.actor.putPreferences({ preferences: newPrefs, }) return newPrefs } finally { this.#prefsLock.release() } } private async updateHiddenPost(postUri: string, action: 'hide' | 'unhide') { await this.updatePreferences((prefs) => { const pref = prefs.findLast(predicate.isValidHiddenPostsPref) || { $type: 'app.bsky.actor.defs#hiddenPostsPref', items: [], } const hiddenItems = new Set(pref.items) if (action === 'hide') hiddenItems.add(postUri) else hiddenItems.delete(postUri) pref.items = [...hiddenItems] return prefs .filter((p) => !AppBskyActorDefs.isHiddenPostsPref(p)) .concat(pref) }) } /** * A helper specifically for updating feed preferences */ private async updateFeedPreferences( cb: ( saved: string[], pinned: string[], ) => { saved: string[]; pinned: string[] }, ): Promise<{ saved: string[]; pinned: string[] }> { let res await this.updatePreferences((prefs) => { const feedsPref = prefs.findLast(predicate.isValidSavedFeedsPref) || { $type: 'app.bsky.actor.defs#savedFeedsPref', saved: [], pinned: [], } res = cb(feedsPref.saved, feedsPref.pinned) feedsPref.saved = res.saved feedsPref.pinned = res.pinned return prefs .filter((pref) => !AppBskyActorDefs.isSavedFeedsPref(pref)) .concat(feedsPref) }) return res } private async updateSavedFeedsV2Preferences( cb: ( savedFeedsPref: AppBskyActorDefs.SavedFeed[], ) => AppBskyActorDefs.SavedFeed[], ): Promise<AppBskyActorDefs.SavedFeed[]> { let maybeMutatedSavedFeeds: AppBskyActorDefs.SavedFeed[] = [] await this.updatePreferences((prefs) => { const existingV2Pref = prefs.findLast( predicate.isValidSavedFeedsPrefV2, ) || { $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [], } const newSavedFeeds = cb(existingV2Pref.items) // enforce ordering: pinned first, then saved existingV2Pref.items = [...newSavedFeeds].sort((a, b) => // @NOTE: preserve order of items with the same pinned status a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1, ) // Store the return value maybeMutatedSavedFeeds = newSavedFeeds 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. */ let existingV1Pref = prefs.findLast(predicate.isValidSavedFeedsPref) 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 } //#endregion } /** * Helper to transform the legacy content preferences. */ function adjustLegacyContentLabelPref( pref: AppBskyActorDefs.ContentLabelPref, ): AppBskyActorDefs.ContentLabelPref { let visibility = pref.visibility // adjust legacy values if (visibility === 'show') { visibility = 'ignore' } return { ...pref, visibility } } /** * Re-maps legacy labels to new labels on READ. Does not save these changes to * the user's preferences. */ function remapLegacyLabels( labels: BskyPreferences['moderationPrefs']['labels'], ) { const _labels = { ...labels } const legacyToNewMap: Record<string, string | undefined> = { gore: 'graphic-media', nsfw: 'porn', suggestive: 'sexual', } for (const labelName in _labels) { const newLabelName = legacyToNewMap[labelName]! if (newLabelName) { _labels[newLabelName] = _labels[labelName] } } return _labels } /** * A helper to get the currently enabled labelers from the full preferences array */ function prefsArrayToLabelerDids( prefs: AppBskyActorDefs.Preferences, ): string[] { const labelersPref = prefs.findLast(predicate.isValidLabelersPref) let dids: string[] = [] if (labelersPref) { dids = (labelersPref as AppBskyActorDefs.LabelersPref).labelers.map( (labeler) => labeler.did, ) } return dids } function isBskyPrefs(v: any): v is BskyPreferences { return ( v && typeof v === 'object' && 'moderationPrefs' in v && isModPrefs(v.moderationPrefs) ) } function isModPrefs(v: any): v is ModerationPrefs { return v && typeof v === 'object' && 'labelers' in v } function migrateLegacyMutedWordsItems(items: AppBskyActorDefs.MutedWord[]) { return items.map((item) => ({ ...item, id: item.id || TID.nextStr(), })) } function matchMutedWord( existingWord: AppBskyActorDefs.MutedWord, newWord: AppBskyActorDefs.MutedWord, ): boolean { // id is undefined in legacy implementation const existingId = existingWord.id // prefer matching based on id const matchById = existingId && existingId === newWord.id // handle legacy case where id is not set const legacyMatchByValue = !existingId && existingWord.value === newWord.value return matchById || legacyMatchByValue }