@atproto/bsky 0.0.66 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/api/app/bsky/feed/getLikes.d.ts.map +1 -1
  3. package/dist/api/app/bsky/feed/getLikes.js +6 -2
  4. package/dist/api/app/bsky/feed/getLikes.js.map +1 -1
  5. package/dist/api/app/bsky/feed/getRepostedBy.d.ts.map +1 -1
  6. package/dist/api/app/bsky/feed/getRepostedBy.js +6 -2
  7. package/dist/api/app/bsky/feed/getRepostedBy.js.map +1 -1
  8. package/dist/api/app/bsky/graph/getLists.d.ts.map +1 -1
  9. package/dist/api/app/bsky/graph/getLists.js +10 -1
  10. package/dist/api/app/bsky/graph/getLists.js.map +1 -1
  11. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
  12. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js +37 -12
  13. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
  14. package/dist/api/com/atproto/repo/getRecord.d.ts.map +1 -1
  15. package/dist/api/com/atproto/repo/getRecord.js +27 -19
  16. package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
  17. package/dist/config.d.ts +4 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +14 -0
  20. package/dist/config.js.map +1 -1
  21. package/dist/context.d.ts +3 -0
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +3 -0
  24. package/dist/context.js.map +1 -1
  25. package/dist/feature-gates.d.ts +25 -0
  26. package/dist/feature-gates.d.ts.map +1 -0
  27. package/dist/feature-gates.js +82 -0
  28. package/dist/feature-gates.js.map +1 -0
  29. package/dist/hydration/hydrator.d.ts +3 -0
  30. package/dist/hydration/hydrator.d.ts.map +1 -1
  31. package/dist/hydration/hydrator.js +67 -44
  32. package/dist/hydration/hydrator.js.map +1 -1
  33. package/dist/hydration/util.d.ts +3 -0
  34. package/dist/hydration/util.d.ts.map +1 -1
  35. package/dist/hydration/util.js +25 -1
  36. package/dist/hydration/util.js.map +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +8 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/lexicon/lexicons.d.ts +5 -0
  41. package/dist/lexicon/lexicons.d.ts.map +1 -1
  42. package/dist/lexicon/lexicons.js +7 -0
  43. package/dist/lexicon/lexicons.js.map +1 -1
  44. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +1 -1
  45. package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
  46. package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
  47. package/dist/lexicon/types/app/bsky/embed/record.d.ts +1 -1
  48. package/dist/lexicon/types/app/bsky/embed/record.d.ts.map +1 -1
  49. package/dist/lexicon/types/app/bsky/embed/record.js.map +1 -1
  50. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +2 -0
  51. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
  52. package/dist/logger.d.ts +4 -1
  53. package/dist/logger.d.ts.map +1 -1
  54. package/dist/logger.js +11 -77
  55. package/dist/logger.js.map +1 -1
  56. package/dist/views/index.d.ts +2 -3
  57. package/dist/views/index.d.ts.map +1 -1
  58. package/dist/views/index.js +21 -13
  59. package/dist/views/index.js.map +1 -1
  60. package/package.json +8 -7
  61. package/src/api/app/bsky/feed/getLikes.ts +6 -2
  62. package/src/api/app/bsky/feed/getRepostedBy.ts +6 -2
  63. package/src/api/app/bsky/graph/getLists.ts +19 -2
  64. package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +55 -15
  65. package/src/api/com/atproto/repo/getRecord.ts +28 -19
  66. package/src/config.ts +20 -0
  67. package/src/context.ts +6 -0
  68. package/src/feature-gates.ts +66 -0
  69. package/src/hydration/hydrator.ts +58 -16
  70. package/src/hydration/util.ts +28 -0
  71. package/src/index.ts +9 -0
  72. package/src/lexicon/lexicons.ts +8 -0
  73. package/src/lexicon/types/app/bsky/actor/defs.ts +1 -0
  74. package/src/lexicon/types/app/bsky/embed/record.ts +1 -0
  75. package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +2 -0
  76. package/src/logger.ts +13 -81
  77. package/src/views/index.ts +18 -17
  78. package/tests/__snapshots__/feed-generation.test.ts.snap +136 -0
  79. package/tests/feed-generation.test.ts +80 -0
  80. package/tests/hydration/util.test.ts +82 -0
  81. package/tests/seed/known-followers.ts +110 -0
  82. package/tests/views/__snapshots__/lists.test.ts.snap +42 -0
  83. package/tests/views/known-followers.test.ts +160 -0
  84. package/tests/views/lists.test.ts +62 -0
  85. package/tests/views/profile.test.ts +0 -35
@@ -23,9 +23,13 @@ export default function (server: Server, ctx: AppContext) {
23
23
  server.app.bsky.feed.getRepostedBy({
24
24
  auth: ctx.authVerifier.standardOptional,
25
25
  handler: async ({ params, auth, req }) => {
26
- const viewer = auth.credentials.iss
26
+ const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth)
27
27
  const labelers = ctx.reqLabelers(req)
28
- const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer })
28
+ const hydrateCtx = await ctx.hydrator.createContext({
29
+ labelers,
30
+ viewer,
31
+ includeTakedowns,
32
+ })
29
33
  const result = await getRepostedBy({ ...params, hydrateCtx }, ctx)
30
34
 
31
35
  return {
@@ -1,12 +1,13 @@
1
1
  import { mapDefined } from '@atproto/common'
2
2
  import { Server } from '../../../../lexicon'
3
3
  import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getLists'
4
+ import { REFERENCELIST } from '../../../../lexicon/types/app/bsky/graph/defs'
4
5
  import AppContext from '../../../../context'
5
6
  import {
6
7
  createPipeline,
7
8
  HydrationFnInput,
8
- noRules,
9
9
  PresentationFnInput,
10
+ RulesFnInput,
10
11
  SkeletonFnInput,
11
12
  } from '../../../../pipeline'
12
13
  import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
@@ -14,7 +15,12 @@ import { Views } from '../../../../views'
14
15
  import { clearlyBadCursor, resHeaders } from '../../../util'
15
16
 
16
17
  export default function (server: Server, ctx: AppContext) {
17
- const getLists = createPipeline(skeleton, hydration, noRules, presentation)
18
+ const getLists = createPipeline(
19
+ skeleton,
20
+ hydration,
21
+ noReferenceLists,
22
+ presentation,
23
+ )
18
24
  server.app.bsky.graph.getLists({
19
25
  auth: ctx.authVerifier.optionalStandardOrRole,
20
26
  handler: async ({ params, auth, req }) => {
@@ -59,6 +65,17 @@ const hydration = async (
59
65
  return ctx.hydrator.hydrateLists(listUris, params.hydrateCtx)
60
66
  }
61
67
 
68
+ const noReferenceLists = (
69
+ input: RulesFnInput<Context, Params, SkeletonState>,
70
+ ) => {
71
+ const { skeleton, hydration } = input
72
+ skeleton.listUris = skeleton.listUris.filter((uri) => {
73
+ const list = hydration.lists?.get(uri)
74
+ return list?.record.purpose !== REFERENCELIST
75
+ })
76
+ return skeleton
77
+ }
78
+
62
79
  const presentation = (
63
80
  input: PresentationFnInput<Context, Params, SkeletonState>,
64
81
  ) => {
@@ -1,5 +1,6 @@
1
- import { mapDefined } from '@atproto/common'
1
+ import { mapDefined, noUndefinedVals } from '@atproto/common'
2
2
  import { InvalidRequestError } from '@atproto/xrpc-server'
3
+ import AtpAgent from '@atproto/api'
3
4
  import { Server } from '../../../../lexicon'
4
5
  import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getSuggestedFollowsByActor'
5
6
  import AppContext from '../../../../context'
@@ -27,14 +28,27 @@ export default function (server: Server, ctx: AppContext) {
27
28
  const viewer = auth.credentials.iss
28
29
  const labelers = ctx.reqLabelers(req)
29
30
  const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer })
30
- const result = await getSuggestedFollowsByActor(
31
- { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) },
32
- ctx,
33
- )
31
+ const headers = noUndefinedVals({
32
+ 'accept-language': req.headers['accept-language'],
33
+ 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics'])
34
+ ? req.headers['x-bsky-topics'].join(',')
35
+ : req.headers['x-bsky-topics'],
36
+ })
37
+ const { headers: resultHeaders, ...result } =
38
+ await getSuggestedFollowsByActor(
39
+ { ...params, hydrateCtx: hydrateCtx.copy({ viewer }), headers },
40
+ ctx,
41
+ )
42
+ const responseHeaders = noUndefinedVals({
43
+ 'content-language': resultHeaders?.['content-language'],
44
+ })
34
45
  return {
35
46
  encoding: 'application/json',
36
47
  body: result,
37
- headers: resHeaders({ labelers: hydrateCtx.labelers }),
48
+ headers: {
49
+ ...responseHeaders,
50
+ ...resHeaders({ labelers: hydrateCtx.labelers }),
51
+ },
38
52
  }
39
53
  },
40
54
  })
@@ -42,17 +56,39 @@ export default function (server: Server, ctx: AppContext) {
42
56
 
43
57
  const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
44
58
  const { params, ctx } = input
59
+ const gates = ctx.featureGates
45
60
  const [relativeToDid] = await ctx.hydrator.actor.getDids([params.actor])
46
61
  if (!relativeToDid) {
47
62
  throw new InvalidRequestError('Actor not found')
48
63
  }
49
- const { dids, cursor } = await ctx.hydrator.dataplane.getFollowSuggestions({
50
- actorDid: params.hydrateCtx.viewer,
51
- relativeToDid,
52
- })
53
- return {
54
- suggestedDids: dids,
55
- cursor: cursor || undefined,
64
+
65
+ if (
66
+ ctx.suggestionsAgent &&
67
+ gates.check(
68
+ await gates.user({ did: params.hydrateCtx.viewer }),
69
+ gates.ids.NewSuggestedFollowsByActor,
70
+ )
71
+ ) {
72
+ const res =
73
+ await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton(
74
+ {
75
+ viewer: params.hydrateCtx.viewer ?? undefined,
76
+ relativeToDid,
77
+ },
78
+ { headers: params.headers },
79
+ )
80
+ return {
81
+ suggestedDids: res.data.actors.map((a) => a.did),
82
+ headers: res.headers,
83
+ }
84
+ } else {
85
+ const { dids } = await ctx.hydrator.dataplane.getFollowSuggestions({
86
+ actorDid: params.hydrateCtx.viewer,
87
+ relativeToDid,
88
+ })
89
+ return {
90
+ suggestedDids: dids,
91
+ }
56
92
  }
57
93
  }
58
94
 
@@ -80,22 +116,26 @@ const presentation = (
80
116
  input: PresentationFnInput<Context, Params, SkeletonState>,
81
117
  ) => {
82
118
  const { ctx, hydration, skeleton } = input
83
- const { suggestedDids } = skeleton
119
+ const { suggestedDids, headers } = skeleton
84
120
  const suggestions = mapDefined(suggestedDids, (did) =>
85
121
  ctx.views.profileDetailed(did, hydration),
86
122
  )
87
- return { suggestions }
123
+ return { suggestions, headers }
88
124
  }
89
125
 
90
126
  type Context = {
91
127
  hydrator: Hydrator
92
128
  views: Views
129
+ suggestionsAgent: AtpAgent | undefined
130
+ featureGates: AppContext['featureGates']
93
131
  }
94
132
 
95
133
  type Params = QueryParams & {
96
134
  hydrateCtx: HydrateCtx & { viewer: string }
135
+ headers: Record<string, string>
97
136
  }
98
137
 
99
138
  type SkeletonState = {
100
139
  suggestedDids: string[]
140
+ headers?: Record<string, string>
101
141
  }
@@ -4,27 +4,36 @@ import { Server } from '../../../../lexicon'
4
4
  import AppContext from '../../../../context'
5
5
 
6
6
  export default function (server: Server, ctx: AppContext) {
7
- server.com.atproto.repo.getRecord(async ({ params }) => {
8
- const { repo, collection, rkey, cid } = params
9
- const [did] = await ctx.hydrator.actor.getDids([repo])
10
- if (!did) {
11
- throw new InvalidRequestError(`Could not find repo: ${repo}`)
12
- }
7
+ server.com.atproto.repo.getRecord({
8
+ auth: ctx.authVerifier.optionalStandardOrRole,
9
+ handler: async ({ auth, params }) => {
10
+ const { repo, collection, rkey, cid } = params
11
+ const { includeTakedowns } = ctx.authVerifier.parseCreds(auth)
12
+ const [did] = await ctx.hydrator.actor.getDids([repo])
13
+ if (!did) {
14
+ throw new InvalidRequestError(`Could not find repo: ${repo}`)
15
+ }
13
16
 
14
- const uri = AtUri.make(did, collection, rkey).toString()
15
- const result = await ctx.hydrator.getRecord(uri, true)
17
+ const actors = await ctx.hydrator.actor.getActors([did], includeTakedowns)
18
+ if (!actors.get(did)) {
19
+ throw new InvalidRequestError(`Could not find repo: ${repo}`)
20
+ }
16
21
 
17
- if (!result || (cid && result.cid !== cid)) {
18
- throw new InvalidRequestError(`Could not locate record: ${uri}`)
19
- }
22
+ const uri = AtUri.make(did, collection, rkey).toString()
23
+ const result = await ctx.hydrator.getRecord(uri, includeTakedowns)
20
24
 
21
- return {
22
- encoding: 'application/json' as const,
23
- body: {
24
- uri: uri,
25
- cid: result.cid,
26
- value: result.record,
27
- },
28
- }
25
+ if (!result || (cid && result.cid !== cid)) {
26
+ throw new InvalidRequestError(`Could not locate record: ${uri}`)
27
+ }
28
+
29
+ return {
30
+ encoding: 'application/json' as const,
31
+ body: {
32
+ uri: uri,
33
+ cid: result.cid,
34
+ value: result.record,
35
+ },
36
+ }
37
+ },
29
38
  })
30
39
  }
package/src/config.ts CHANGED
@@ -35,6 +35,8 @@ export interface ServerConfigValues {
35
35
  labelsFromIssuerDids?: string[]
36
36
  // misc/dev
37
37
  blobCacheLocation?: string
38
+ statsigKey?: string
39
+ statsigEnv?: string
38
40
  }
39
41
 
40
42
  export class ServerConfig {
@@ -102,6 +104,14 @@ export class ServerConfig {
102
104
  assert(modServiceDid)
103
105
  assert(dataplaneUrls.length)
104
106
  assert(dataplaneHttpVersion === '1.1' || dataplaneHttpVersion === '2')
107
+ const statsigKey =
108
+ process.env.NODE_ENV === 'test'
109
+ ? 'secret-key'
110
+ : process.env.BSKY_STATSIG_KEY || undefined
111
+ const statsigEnv =
112
+ process.env.NODE_ENV === 'test'
113
+ ? 'test'
114
+ : process.env.BSKY_STATSIG_ENV || 'development'
105
115
  return new ServerConfig({
106
116
  version,
107
117
  debugMode,
@@ -132,6 +142,8 @@ export class ServerConfig {
132
142
  blobRateLimitBypassHostname,
133
143
  adminPasswords,
134
144
  modServiceDid,
145
+ statsigKey,
146
+ statsigEnv,
135
147
  ...stripUndefineds(overrides ?? {}),
136
148
  })
137
149
  }
@@ -264,6 +276,14 @@ export class ServerConfig {
264
276
  get blobCacheLocation() {
265
277
  return this.cfg.blobCacheLocation
266
278
  }
279
+
280
+ get statsigKey() {
281
+ return this.cfg.statsigKey
282
+ }
283
+
284
+ get statsigEnv() {
285
+ return this.cfg.statsigEnv
286
+ }
267
287
  }
268
288
 
269
289
  function stripUndefineds(
package/src/context.ts CHANGED
@@ -11,6 +11,7 @@ import { Views } from './views'
11
11
  import { AuthVerifier } from './auth-verifier'
12
12
  import { BsyncClient } from './bsync'
13
13
  import { CourierClient } from './courier'
14
+ import { FeatureGates } from './feature-gates'
14
15
  import {
15
16
  ParsedLabelers,
16
17
  defaultLabelerHeader,
@@ -32,6 +33,7 @@ export class AppContext {
32
33
  bsyncClient: BsyncClient
33
34
  courierClient: CourierClient
34
35
  authVerifier: AuthVerifier
36
+ featureGates: FeatureGates
35
37
  },
36
38
  ) {}
37
39
 
@@ -83,6 +85,10 @@ export class AppContext {
83
85
  return this.opts.authVerifier
84
86
  }
85
87
 
88
+ get featureGates(): FeatureGates {
89
+ return this.opts.featureGates
90
+ }
91
+
86
92
  async serviceAuthJwt(aud: string) {
87
93
  const iss = this.cfg.serverDid
88
94
  return createServiceJwt({
@@ -0,0 +1,66 @@
1
+ import { Statsig, StatsigUser } from 'statsig-node'
2
+ import { sha256Hex } from '@atproto/crypto'
3
+
4
+ import { featureGatesLogger } from './logger'
5
+ import type { ServerConfig } from './config'
6
+
7
+ export type Config = {
8
+ apiKey?: string
9
+ env?: 'development' | 'staging' | 'production' | string
10
+ }
11
+
12
+ export enum GateID {
13
+ NewSuggestedFollowsByActor = 'new_sugg_foll_by_actor',
14
+ }
15
+
16
+ /**
17
+ * @see https://docs.statsig.com/server/nodejsServerSDK
18
+ */
19
+ export class FeatureGates {
20
+ ready = false
21
+ private statsig = Statsig
22
+ ids = GateID
23
+
24
+ constructor(private config: Config) {}
25
+
26
+ async start() {
27
+ try {
28
+ if (this.config.apiKey) {
29
+ /**
30
+ * Special handling in test mode, see {@link ServerConfig}
31
+ *
32
+ * {@link https://docs.statsig.com/server/nodejsServerSDK#local-overrides}
33
+ */
34
+ await this.statsig.initialize(this.config.apiKey, {
35
+ localMode: this.config.env === 'test',
36
+ environment: {
37
+ tier: this.config.env || 'development',
38
+ },
39
+ })
40
+ this.ready = true
41
+ }
42
+ } catch (err) {
43
+ featureGatesLogger.error({ err }, 'Failed to initialize StatSig')
44
+ this.ready = false
45
+ }
46
+ }
47
+
48
+ destroy() {
49
+ if (this.ready) {
50
+ this.ready = false
51
+ this.statsig.shutdown()
52
+ }
53
+ }
54
+
55
+ async user({ did }: { did: string }): Promise<StatsigUser> {
56
+ const userID = await sha256Hex(did)
57
+ return {
58
+ userID,
59
+ }
60
+ }
61
+
62
+ check(user: StatsigUser, gate: GateID) {
63
+ if (!this.ready) return false
64
+ return this.statsig.checkGateSync(user, gate)
65
+ }
66
+ }
@@ -36,11 +36,13 @@ import {
36
36
  } from './label'
37
37
  import {
38
38
  HydrationMap,
39
- Merges,
40
39
  RecordInfo,
41
40
  ItemRef,
42
41
  didFromUri,
43
42
  urisByCollection,
43
+ mergeMaps,
44
+ mergeNestedMaps,
45
+ mergeManyMaps,
44
46
  } from './util'
45
47
  import {
46
48
  FeedGenAggs,
@@ -57,6 +59,7 @@ import {
57
59
  FeedItem,
58
60
  } from './feed'
59
61
  import { ParsedLabelers } from '../util'
62
+ import starterPack from '../data-plane/server/indexing/plugins/starter-pack'
60
63
 
61
64
  export class HydrateCtx {
62
65
  labelers = this.vals.labelers
@@ -102,6 +105,7 @@ export type HydrationState = {
102
105
  labelerViewers?: LabelerViewerStates
103
106
  labelerAggs?: LabelerAggs
104
107
  knownFollowers?: KnownFollowers
108
+ bidirectionalBlocks?: BidirectionalBlocks
105
109
  }
106
110
 
107
111
  export type PostBlock = { embed: boolean; reply: boolean }
@@ -111,6 +115,8 @@ type PostBlockPairs = { embed?: RelationshipPair; reply?: RelationshipPair }
111
115
  export type FollowBlock = boolean
112
116
  export type FollowBlocks = HydrationMap<FollowBlock>
113
117
 
118
+ export type BidirectionalBlocks = HydrationMap<HydrationMap<boolean>>
119
+
114
120
  export class Hydrator {
115
121
  actor: ActorHydrator
116
122
  feed: FeedHydrator
@@ -215,13 +221,23 @@ export class Hydrator {
215
221
  )
216
222
  }
217
223
 
218
- const knownFollowersDids = Array.from(knownFollowers.values())
224
+ const subjectsToKnownFollowersMap = Array.from(
225
+ knownFollowers.keys(),
226
+ ).reduce((acc, did) => {
227
+ const known = knownFollowers.get(did)
228
+ if (known) {
229
+ acc.set(did, known.followers)
230
+ }
231
+ return acc
232
+ }, new Map<string, string[]>())
233
+ const allKnownFollowerDids = Array.from(knownFollowers.values())
219
234
  .filter(Boolean)
220
235
  .flatMap((f) => f!.followers)
221
- const allDids = Array.from(new Set(dids.concat(knownFollowersDids)))
222
- const [state, profileAggs] = await Promise.all([
236
+ const allDids = Array.from(new Set(dids.concat(allKnownFollowerDids)))
237
+ const [state, profileAggs, bidirectionalBlocks] = await Promise.all([
223
238
  this.hydrateProfiles(allDids, ctx),
224
239
  this.actor.getProfileAggregates(dids),
240
+ this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap),
225
241
  ])
226
242
  const starterPackUriSet = new Set<string>()
227
243
  state.actors?.forEach((actor) => {
@@ -237,6 +253,7 @@ export class Hydrator {
237
253
  profileAggs,
238
254
  knownFollowers,
239
255
  ctx,
256
+ bidirectionalBlocks,
240
257
  })
241
258
  }
242
259
 
@@ -352,6 +369,10 @@ export class Hydrator {
352
369
  ...(urisLayer1ByCollection.get(ids.AppBskyLabelerService) ?? []),
353
370
  ...(urisLayer2ByCollection.get(ids.AppBskyLabelerService) ?? []),
354
371
  ].map((uri) => new AtUri(uri).hostname)
372
+ const nestedStarterPackUris = [
373
+ ...(urisLayer1ByCollection.get(ids.AppBskyGraphStarterpack) ?? []),
374
+ ...(urisLayer2ByCollection.get(ids.AppBskyGraphStarterpack) ?? []),
375
+ ]
355
376
  const posts =
356
377
  mergeManyMaps(postsLayer0, postsLayer1, postsLayer2) ?? postsLayer0
357
378
  const allPostUris = [...posts.keys()]
@@ -374,6 +395,7 @@ export class Hydrator {
374
395
  listState,
375
396
  feedGenState,
376
397
  labelerState,
398
+ starterPackState,
377
399
  ] = await Promise.all([
378
400
  this.feed.getPostAggregates(allRefs),
379
401
  ctx.viewer
@@ -385,6 +407,7 @@ export class Hydrator {
385
407
  this.hydrateLists([...nestedListUris, ...gateListUris], ctx),
386
408
  this.hydrateFeedGens(nestedFeedGenUris, ctx),
387
409
  this.hydrateLabelers(nestedLabelerDids, ctx),
410
+ this.hydrateStarterPacksBasic(nestedStarterPackUris, ctx),
388
411
  ])
389
412
  if (!ctx.includeTakedowns) {
390
413
  actionTakedownLabels(allPostUris, posts, labels)
@@ -395,6 +418,7 @@ export class Hydrator {
395
418
  listState,
396
419
  feedGenState,
397
420
  labelerState,
421
+ starterPackState,
398
422
  {
399
423
  posts,
400
424
  postAggs,
@@ -661,7 +685,7 @@ export class Hydrator {
661
685
  // - list basic
662
686
  async hydrateLikes(uris: string[], ctx: HydrateCtx): Promise<HydrationState> {
663
687
  const [likes, profileState] = await Promise.all([
664
- this.feed.getLikes(uris),
688
+ this.feed.getLikes(uris, ctx.includeTakedowns),
665
689
  this.hydrateProfiles(uris.map(didFromUri), ctx),
666
690
  ])
667
691
  return mergeStates(profileState, { likes, ctx })
@@ -673,7 +697,7 @@ export class Hydrator {
673
697
  // - list basic
674
698
  async hydrateReposts(uris: string[], ctx: HydrateCtx) {
675
699
  const [reposts, profileState] = await Promise.all([
676
- this.feed.getReposts(uris),
700
+ this.feed.getReposts(uris, ctx.includeTakedowns),
677
701
  this.hydrateProfiles(uris.map(didFromUri), ctx),
678
702
  ])
679
703
  return mergeStates(profileState, { reposts, ctx })
@@ -737,6 +761,30 @@ export class Hydrator {
737
761
  return { follows, followBlocks }
738
762
  }
739
763
 
764
+ async hydrateBidirectionalBlocks(
765
+ didMap: Map<string, string[]>, // DID -> DID[]
766
+ ): Promise<BidirectionalBlocks> {
767
+ const pairs: RelationshipPair[] = []
768
+ for (const [source, targets] of didMap) {
769
+ for (const target of targets) {
770
+ pairs.push([source, target])
771
+ }
772
+ }
773
+
774
+ const result = new HydrationMap<HydrationMap<boolean>>()
775
+ const blocks = await this.graph.getBidirectionalBlocks(pairs)
776
+
777
+ for (const [source, targets] of didMap) {
778
+ const didBlocks = new HydrationMap<boolean>()
779
+ for (const target of targets) {
780
+ didBlocks.set(target, blocks.isBlocked(source, target))
781
+ }
782
+ result.set(source, didBlocks)
783
+ }
784
+
785
+ return result
786
+ }
787
+
740
788
  // app.bsky.labeler.def#labelerViewDetailed
741
789
  // - labeler
742
790
  // - profile
@@ -1022,23 +1070,17 @@ export const mergeStates = (
1022
1070
  labelerAggs: mergeMaps(stateA.labelerAggs, stateB.labelerAggs),
1023
1071
  labelerViewers: mergeMaps(stateA.labelerViewers, stateB.labelerViewers),
1024
1072
  knownFollowers: mergeMaps(stateA.knownFollowers, stateB.knownFollowers),
1073
+ bidirectionalBlocks: mergeNestedMaps(
1074
+ stateA.bidirectionalBlocks,
1075
+ stateB.bidirectionalBlocks,
1076
+ ),
1025
1077
  }
1026
1078
  }
1027
1079
 
1028
- const mergeMaps = <M extends Merges>(mapA?: M, mapB?: M): M | undefined => {
1029
- if (!mapA) return mapB
1030
- if (!mapB) return mapA
1031
- return mapA.merge(mapB)
1032
- }
1033
-
1034
1080
  const mergeManyStates = (...states: HydrationState[]) => {
1035
1081
  return states.reduce(mergeStates, {} as HydrationState)
1036
1082
  }
1037
1083
 
1038
- const mergeManyMaps = <T>(...maps: HydrationMap<T>[]) => {
1039
- return maps.reduce(mergeMaps, undefined as HydrationMap<T> | undefined)
1040
- }
1041
-
1042
1084
  const actionTakedownLabels = <T>(
1043
1085
  keys: string[],
1044
1086
  hydrationMap: HydrationMap<T>,
@@ -25,6 +25,34 @@ export type RecordInfo<T> = {
25
25
  takedownRef: string | undefined
26
26
  }
27
27
 
28
+ export const mergeMaps = <V, M extends HydrationMap<V>>(
29
+ mapA?: M,
30
+ mapB?: M,
31
+ ): M | undefined => {
32
+ if (!mapA) return mapB
33
+ if (!mapB) return mapA
34
+ return mapA.merge(mapB)
35
+ }
36
+
37
+ export const mergeNestedMaps = <V, M extends HydrationMap<HydrationMap<V>>>(
38
+ mapA?: M,
39
+ mapB?: M,
40
+ ): M | undefined => {
41
+ if (!mapA) return mapB
42
+ if (!mapB) return mapA
43
+
44
+ for (const [key, map] of mapB) {
45
+ const merged = mergeMaps(mapA.get(key) ?? undefined, map ?? undefined)
46
+ mapA.set(key, merged ?? null)
47
+ }
48
+
49
+ return mapA
50
+ }
51
+
52
+ export const mergeManyMaps = <T>(...maps: HydrationMap<T>[]) => {
53
+ return maps.reduce(mergeMaps, undefined as HydrationMap<T> | undefined)
54
+ }
55
+
28
56
  export type ItemRef = { uri: string; cid?: string }
29
57
 
30
58
  export const parseRecord = <T>(
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import { Views } from './views'
23
23
  import { AuthVerifier } from './auth-verifier'
24
24
  import { authWithApiKey as bsyncAuth, createBsyncClient } from './bsync'
25
25
  import { authWithApiKey as courierAuth, createCourierClient } from './courier'
26
+ import { FeatureGates } from './feature-gates'
26
27
 
27
28
  export * from './data-plane'
28
29
  export type { ServerConfigValues } from './config'
@@ -116,6 +117,11 @@ export class BskyAppView {
116
117
  adminPasses: config.adminPasswords,
117
118
  })
118
119
 
120
+ const featureGates = new FeatureGates({
121
+ apiKey: config.statsigKey,
122
+ env: config.statsigEnv,
123
+ })
124
+
119
125
  const ctx = new AppContext({
120
126
  cfg: config,
121
127
  dataplane,
@@ -128,6 +134,7 @@ export class BskyAppView {
128
134
  bsyncClient,
129
135
  courierClient,
130
136
  authVerifier,
137
+ featureGates,
131
138
  })
132
139
 
133
140
  let server = createServer({
@@ -154,6 +161,7 @@ export class BskyAppView {
154
161
  }
155
162
 
156
163
  async start(): Promise<http.Server> {
164
+ await this.ctx.featureGates.start()
157
165
  const server = this.app.listen(this.ctx.cfg.port)
158
166
  this.server = server
159
167
  server.keepAliveTimeout = 90000
@@ -166,6 +174,7 @@ export class BskyAppView {
166
174
 
167
175
  async destroy(): Promise<void> {
168
176
  await this.terminator?.terminate()
177
+ this.ctx.featureGates.destroy()
169
178
  }
170
179
  }
171
180
 
@@ -4167,6 +4167,7 @@ export const schemaDict = {
4167
4167
  'lex:app.bsky.actor.defs#mutedWordsPref',
4168
4168
  'lex:app.bsky.actor.defs#hiddenPostsPref',
4169
4169
  'lex:app.bsky.actor.defs#bskyAppStatePref',
4170
+ 'lex:app.bsky.actor.defs#labelersPref',
4170
4171
  ],
4171
4172
  },
4172
4173
  },
@@ -4957,6 +4958,7 @@ export const schemaDict = {
4957
4958
  'lex:app.bsky.feed.defs#generatorView',
4958
4959
  'lex:app.bsky.graph.defs#listView',
4959
4960
  'lex:app.bsky.labeler.defs#labelerView',
4961
+ 'lex:app.bsky.graph.defs#starterPackViewBasic',
4960
4962
  ],
4961
4963
  },
4962
4964
  },
@@ -8710,6 +8712,12 @@ export const schemaDict = {
8710
8712
  cursor: {
8711
8713
  type: 'string',
8712
8714
  },
8715
+ relativeToDid: {
8716
+ type: 'string',
8717
+ format: 'did',
8718
+ description:
8719
+ 'DID of the account to get suggestions relative to. If not provided, suggestions will be based on the viewer.',
8720
+ },
8713
8721
  },
8714
8722
  },
8715
8723
  output: {
@@ -185,6 +185,7 @@ export type Preferences = (
185
185
  | MutedWordsPref
186
186
  | HiddenPostsPref
187
187
  | BskyAppStatePref
188
+ | LabelersPref
188
189
  | { $type: string; [k: string]: unknown }
189
190
  )[]
190
191
 
@@ -41,6 +41,7 @@ export interface View {
41
41
  | AppBskyFeedDefs.GeneratorView
42
42
  | AppBskyGraphDefs.ListView
43
43
  | AppBskyLabelerDefs.LabelerView
44
+ | AppBskyGraphDefs.StarterPackViewBasic
44
45
  | { $type: string; [k: string]: unknown }
45
46
  [k: string]: unknown
46
47
  }