@atproto/bsky 0.0.43 → 0.0.44

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 (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts +1 -0
  3. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  4. package/dist/api/app/bsky/feed/getAuthorFeed.js +84 -4
  5. package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
  6. package/dist/api/app/bsky/notification/registerPush.js.map +1 -1
  7. package/dist/auth-verifier.d.ts.map +1 -1
  8. package/dist/auth-verifier.js.map +1 -1
  9. package/dist/cache/read-through.d.ts.map +1 -1
  10. package/dist/cache/read-through.js.map +1 -1
  11. package/dist/data-plane/server/db/pagination.d.ts.map +1 -1
  12. package/dist/data-plane/server/db/pagination.js.map +1 -1
  13. package/dist/data-plane/server/index.d.ts.map +1 -1
  14. package/dist/data-plane/server/index.js.map +1 -1
  15. package/dist/data-plane/server/indexing/index.d.ts.map +1 -1
  16. package/dist/data-plane/server/indexing/index.js.map +1 -1
  17. package/dist/data-plane/server/subscription/index.d.ts.map +1 -1
  18. package/dist/data-plane/server/subscription/index.js.map +1 -1
  19. package/dist/data-plane/server/subscription/util.d.ts.map +1 -1
  20. package/dist/data-plane/server/subscription/util.js.map +1 -1
  21. package/dist/hydration/actor.d.ts.map +1 -1
  22. package/dist/hydration/actor.js.map +1 -1
  23. package/dist/hydration/graph.d.ts.map +1 -1
  24. package/dist/hydration/graph.js.map +1 -1
  25. package/dist/image/server.d.ts.map +1 -1
  26. package/dist/image/server.js.map +1 -1
  27. package/dist/lexicon/lexicons.d.ts +14 -0
  28. package/dist/lexicon/lexicons.d.ts.map +1 -1
  29. package/dist/lexicon/lexicons.js +15 -1
  30. package/dist/lexicon/lexicons.js.map +1 -1
  31. package/dist/lexicon/types/com/atproto/sync/getRecord.d.ts +1 -1
  32. package/dist/lexicon/types/com/atproto/sync/getRecord.d.ts.map +1 -1
  33. package/package.json +3 -3
  34. package/src/api/app/bsky/feed/getAuthorFeed.ts +80 -5
  35. package/src/api/app/bsky/notification/registerPush.ts +2 -2
  36. package/src/auth-verifier.ts +4 -1
  37. package/src/cache/read-through.ts +13 -7
  38. package/src/data-plane/server/db/pagination.ts +4 -1
  39. package/src/data-plane/server/index.ts +4 -1
  40. package/src/data-plane/server/indexing/index.ts +10 -7
  41. package/src/data-plane/server/subscription/index.ts +2 -3
  42. package/src/data-plane/server/subscription/util.ts +4 -1
  43. package/src/hydration/actor.ts +10 -7
  44. package/src/hydration/graph.ts +8 -5
  45. package/src/image/server.ts +4 -1
  46. package/src/lexicon/lexicons.ts +16 -1
  47. package/src/lexicon/types/com/atproto/sync/getRecord.ts +1 -1
  48. package/tests/_util.ts +4 -4
  49. package/tests/views/author-feed.test.ts +41 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.43",
3
+ "version": "0.0.44",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  "@atproto/lexicon": "^0.4.0",
47
47
  "@atproto/repo": "^0.4.0",
48
48
  "@atproto/syntax": "^0.3.0",
49
- "@atproto/xrpc-server": "^0.5.0"
49
+ "@atproto/xrpc-server": "^0.5.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@bufbuild/buf": "^1.28.1",
@@ -63,7 +63,7 @@
63
63
  "ts-node": "^10.8.2",
64
64
  "@atproto/api": "^0.12.2",
65
65
  "@atproto/lex-cli": "^0.4.0",
66
- "@atproto/pds": "^0.4.11",
66
+ "@atproto/pds": "^0.4.13",
67
67
  "@atproto/xrpc": "^0.5.0"
68
68
  },
69
69
  "scripts": {
@@ -15,7 +15,7 @@ import { Views } from '../../../../views'
15
15
  import { DataPlaneClient } from '../../../../data-plane'
16
16
  import { parseString } from '../../../../hydration/util'
17
17
  import { Actor } from '../../../../hydration/actor'
18
- import { FeedItem } from '../../../../hydration/feed'
18
+ import { FeedItem, Post } from '../../../../hydration/feed'
19
19
  import { FeedType } from '../../../../proto/bsky_pb'
20
20
 
21
21
  export default function (server: Server, ctx: AppContext) {
@@ -77,7 +77,7 @@ export const skeleton = async (inputs: {
77
77
  throw new InvalidRequestError('Profile not found')
78
78
  }
79
79
  if (clearlyBadCursor(params.cursor)) {
80
- return { actor, items: [] }
80
+ return { actor, filter: params.filter, items: [] }
81
81
  }
82
82
  const res = await ctx.dataplane.getAuthorFeed({
83
83
  actorDid: did,
@@ -87,6 +87,7 @@ export const skeleton = async (inputs: {
87
87
  })
88
88
  return {
89
89
  actor,
90
+ filter: params.filter,
90
91
  items: res.items.map((item) => ({
91
92
  post: { uri: item.uri, cid: item.cid || undefined },
92
93
  repost: item.repost
@@ -129,14 +130,30 @@ const noBlocksOrMutedReposts = (inputs: {
129
130
  'BlockedByActor',
130
131
  )
131
132
  }
132
- skeleton.items = skeleton.items.filter((item) => {
133
+
134
+ const checkBlocksAndMutes = (item: FeedItem) => {
133
135
  const bam = ctx.views.feedItemBlocksAndMutes(item, hydration)
134
136
  return (
135
137
  !bam.authorBlocked &&
136
138
  !bam.originatorBlocked &&
137
- !(bam.authorMuted && !bam.originatorMuted)
139
+ (!bam.authorMuted || bam.originatorMuted) // repost of muted content
138
140
  )
139
- })
141
+ }
142
+
143
+ if (skeleton.filter === 'posts_and_author_threads') {
144
+ // ensure replies are only included if the feed contains all
145
+ // replies up to the thread root (i.e. a complete self-thread.)
146
+ const selfThread = new SelfThreadTracker(skeleton.items, hydration)
147
+ skeleton.items = skeleton.items.filter((item) => {
148
+ return (
149
+ checkBlocksAndMutes(item) &&
150
+ (item.repost || selfThread.ok(item.post.uri))
151
+ )
152
+ })
153
+ } else {
154
+ skeleton.items = skeleton.items.filter(checkBlocksAndMutes)
155
+ }
156
+
140
157
  return skeleton
141
158
  }
142
159
 
@@ -165,5 +182,63 @@ type Params = QueryParams & {
165
182
  type Skeleton = {
166
183
  actor: Actor
167
184
  items: FeedItem[]
185
+ filter: QueryParams['filter']
168
186
  cursor?: string
169
187
  }
188
+
189
+ class SelfThreadTracker {
190
+ feedUris = new Set<string>()
191
+ cache = new Map<string, boolean>()
192
+
193
+ constructor(
194
+ items: FeedItem[],
195
+ private hydration: HydrationState,
196
+ ) {
197
+ items.forEach((item) => {
198
+ if (!item.repost) {
199
+ this.feedUris.add(item.post.uri)
200
+ }
201
+ })
202
+ }
203
+
204
+ ok(uri: string, loop = new Set<string>()) {
205
+ // if we've already checked this uri, pull from the cache
206
+ if (this.cache.has(uri)) {
207
+ return this.cache.get(uri) ?? false
208
+ }
209
+ // loop detection
210
+ if (loop.has(uri)) {
211
+ this.cache.set(uri, false)
212
+ return false
213
+ } else {
214
+ loop.add(uri)
215
+ }
216
+ // cache through the result
217
+ const result = this._ok(uri, loop)
218
+ this.cache.set(uri, result)
219
+ return result
220
+ }
221
+
222
+ private _ok(uri: string, loop: Set<string>): boolean {
223
+ // must be in the feed to be in a self-thread
224
+ if (!this.feedUris.has(uri)) {
225
+ return false
226
+ }
227
+ // must be hydratable to be part of self-thread
228
+ const post = this.hydration.posts?.get(uri)
229
+ if (!post) {
230
+ return false
231
+ }
232
+ // root posts (no parent) are trivial case of self-thread
233
+ const parentUri = getParentUri(post)
234
+ if (parentUri === null) {
235
+ return true
236
+ }
237
+ // recurse w/ cache: this post is in a self-thread if its parent is.
238
+ return this.ok(parentUri, loop)
239
+ }
240
+ }
241
+
242
+ function getParentUri(post: Post) {
243
+ return post.record.reply?.parent.uri ?? null
244
+ }
@@ -24,8 +24,8 @@ export default function (server: Server, ctx: AppContext) {
24
24
  platform === 'ios'
25
25
  ? AppPlatform.IOS
26
26
  : platform === 'android'
27
- ? AppPlatform.ANDROID
28
- : AppPlatform.WEB,
27
+ ? AppPlatform.ANDROID
28
+ : AppPlatform.WEB,
29
29
  appId,
30
30
  })
31
31
  },
@@ -64,7 +64,10 @@ export class AuthVerifier {
64
64
  public modServiceDid: string
65
65
  private adminPasses: Set<string>
66
66
 
67
- constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) {
67
+ constructor(
68
+ public dataplane: DataPlaneClient,
69
+ opts: AuthVerifierOpts,
70
+ ) {
68
71
  this.ownDid = opts.ownDid
69
72
  this.modServiceDid = opts.modServiceDid
70
73
  this.adminPasses = new Set(opts.adminPasses)
@@ -14,7 +14,10 @@ export type CacheOptions<T> = {
14
14
  }
15
15
 
16
16
  export class ReadThroughCache<T> {
17
- constructor(public redis: Redis, public opts: CacheOptions<T>) {}
17
+ constructor(
18
+ public redis: Redis,
19
+ public opts: CacheOptions<T>,
20
+ ) {}
18
21
 
19
22
  private async _fetchMany(keys: string[]): Promise<Record<string, T | null>> {
20
23
  let result: Record<string, T | null> = {}
@@ -142,10 +145,13 @@ export class ReadThroughCache<T> {
142
145
  }
143
146
 
144
147
  const removeNulls = <T>(obj: Record<string, T | null>): Record<string, T> => {
145
- return Object.entries(obj).reduce((acc, [key, val]) => {
146
- if (val !== null) {
147
- acc[key] = val
148
- }
149
- return acc
150
- }, {} as Record<string, T>)
148
+ return Object.entries(obj).reduce(
149
+ (acc, [key, val]) => {
150
+ if (val !== null) {
151
+ acc[key] = val
152
+ }
153
+ return acc
154
+ },
155
+ {} as Record<string, T>,
156
+ )
151
157
  }
@@ -23,7 +23,10 @@ export type LabeledResult = {
23
23
  * ↳ SQL Condition
24
24
  */
25
25
  export abstract class GenericKeyset<R, LR extends LabeledResult> {
26
- constructor(public primary: DbRef, public secondary: DbRef) {}
26
+ constructor(
27
+ public primary: DbRef,
28
+ public secondary: DbRef,
29
+ ) {}
27
30
  abstract labelResult(result: R): LR
28
31
  abstract labeledResultToCursor(labeled: LR): Cursor
29
32
  abstract cursorToLabeledResult(cursor: Cursor): LR
@@ -9,7 +9,10 @@ import { IdResolver, MemoryCache } from '@atproto/identity'
9
9
  export { RepoSubscription } from './subscription'
10
10
 
11
11
  export class DataPlaneServer {
12
- constructor(public server: http.Server, public idResolver: IdResolver) {}
12
+ constructor(
13
+ public server: http.Server,
14
+ public idResolver: IdResolver,
15
+ ) {}
13
16
 
14
17
  static async create(db: Database, port: number, plcUrl?: string) {
15
18
  const app = express()
@@ -206,13 +206,16 @@ export class IndexingService {
206
206
  .where('did', '=', did)
207
207
  .select(['uri', 'cid'])
208
208
  .execute()
209
- return res.reduce((acc, cur) => {
210
- acc[cur.uri] = {
211
- uri: new AtUri(cur.uri),
212
- cid: CID.parse(cur.cid),
213
- }
214
- return acc
215
- }, {} as Record<string, { uri: AtUri; cid: CID }>)
209
+ return res.reduce(
210
+ (acc, cur) => {
211
+ acc[cur.uri] = {
212
+ uri: new AtUri(cur.uri),
213
+ cid: CID.parse(cur.cid),
214
+ }
215
+ return acc
216
+ },
217
+ {} as Record<string, { uri: AtUri; cid: CID }>,
218
+ )
216
219
  }
217
220
 
218
221
  async setCommitLastSeen(
@@ -134,9 +134,8 @@ export class RepoSubscription {
134
134
  return
135
135
  }
136
136
  if (msg.rebase) {
137
- const needsReindex = await this.indexingSvc.checkCommitNeedsIndexing(
138
- root,
139
- )
137
+ const needsReindex =
138
+ await this.indexingSvc.checkCommitNeedsIndexing(root)
140
139
  if (needsReindex) {
141
140
  await this.indexingSvc.indexRepo(msg.repo, rootCid.toString())
142
141
  }
@@ -88,7 +88,10 @@ export class ConsecutiveList<T> {
88
88
 
89
89
  export class ConsecutiveItem<T> {
90
90
  isComplete = false
91
- constructor(private consecutive: ConsecutiveList<T>, public value: T) {}
91
+ constructor(
92
+ private consecutive: ConsecutiveList<T>,
93
+ public value: T,
94
+ ) {}
92
95
 
93
96
  complete() {
94
97
  this.isComplete = true
@@ -61,13 +61,16 @@ export class ActorHydrator {
61
61
  const res = handles.length
62
62
  ? await this.dataplane.getDidsByHandles({ handles })
63
63
  : { dids: [] }
64
- const didByHandle = handles.reduce((acc, cur, i) => {
65
- const did = res.dids[i]
66
- if (did && did.length > 0) {
67
- return acc.set(cur, did)
68
- }
69
- return acc
70
- }, new Map() as Map<string, string>)
64
+ const didByHandle = handles.reduce(
65
+ (acc, cur, i) => {
66
+ const did = res.dids[i]
67
+ if (did && did.length > 0) {
68
+ return acc.set(cur, did)
69
+ }
70
+ return acc
71
+ },
72
+ new Map() as Map<string, string>,
73
+ )
71
74
  return handleOrDids.map((id) =>
72
75
  id.startsWith('did:') ? id : didByHandle.get(id),
73
76
  )
@@ -28,11 +28,14 @@ export type Block = RecordInfo<BlockRecord>
28
28
  export type RelationshipPair = [didA: string, didB: string]
29
29
 
30
30
  const dedupePairs = (pairs: RelationshipPair[]): RelationshipPair[] => {
31
- const mapped = pairs.reduce((acc, cur) => {
32
- const sorted = ([...cur] as RelationshipPair).sort()
33
- acc[sorted.join('-')] = sorted
34
- return acc
35
- }, {} as Record<string, RelationshipPair>)
31
+ const mapped = pairs.reduce(
32
+ (acc, cur) => {
33
+ const sorted = ([...cur] as RelationshipPair).sort()
34
+ acc[sorted.join('-')] = sorted
35
+ return acc
36
+ },
37
+ {} as Record<string, RelationshipPair>,
38
+ )
36
39
  return Object.values(mapped)
37
40
  }
38
41
 
@@ -29,7 +29,10 @@ export class ImageProcessingServer {
29
29
  app: Express = express()
30
30
  uriBuilder: ImageUriBuilder
31
31
 
32
- constructor(public cfg: ServerConfig, public cache: BlobCache) {
32
+ constructor(
33
+ public cfg: ServerConfig,
34
+ public cache: BlobCache,
35
+ ) {
33
36
  this.uriBuilder = new ImageUriBuilder('')
34
37
  this.app.get('*', this.handler.bind(this))
35
38
  this.app.use(errorMiddleware)
@@ -1067,6 +1067,8 @@ export const schemaDict = {
1067
1067
  },
1068
1068
  reason: {
1069
1069
  type: 'string',
1070
+ maxGraphemes: 2000,
1071
+ maxLength: 20000,
1070
1072
  description:
1071
1073
  'Additional context about the content and violation.',
1072
1074
  },
@@ -2438,9 +2440,11 @@ export const schemaDict = {
2438
2440
  properties: {
2439
2441
  privacyPolicy: {
2440
2442
  type: 'string',
2443
+ format: 'uri',
2441
2444
  },
2442
2445
  termsOfService: {
2443
2446
  type: 'string',
2447
+ format: 'uri',
2444
2448
  },
2445
2449
  },
2446
2450
  },
@@ -3052,7 +3056,8 @@ export const schemaDict = {
3052
3056
  commit: {
3053
3057
  type: 'string',
3054
3058
  format: 'cid',
3055
- description: 'An optional past commit CID.',
3059
+ description:
3060
+ 'DEPRECATED: referenced a repo commit by CID, and retrieved record as of that commit',
3056
3061
  },
3057
3062
  },
3058
3063
  },
@@ -3621,6 +3626,7 @@ export const schemaDict = {
3621
3626
  },
3622
3627
  avatar: {
3623
3628
  type: 'string',
3629
+ format: 'uri',
3624
3630
  },
3625
3631
  associated: {
3626
3632
  type: 'ref',
@@ -3663,6 +3669,7 @@ export const schemaDict = {
3663
3669
  },
3664
3670
  avatar: {
3665
3671
  type: 'string',
3672
+ format: 'uri',
3666
3673
  },
3667
3674
  associated: {
3668
3675
  type: 'ref',
@@ -3709,9 +3716,11 @@ export const schemaDict = {
3709
3716
  },
3710
3717
  avatar: {
3711
3718
  type: 'string',
3719
+ format: 'uri',
3712
3720
  },
3713
3721
  banner: {
3714
3722
  type: 'string',
3723
+ format: 'uri',
3715
3724
  },
3716
3725
  followersCount: {
3717
3726
  type: 'integer',
@@ -4388,6 +4397,7 @@ export const schemaDict = {
4388
4397
  },
4389
4398
  thumb: {
4390
4399
  type: 'string',
4400
+ format: 'uri',
4391
4401
  },
4392
4402
  },
4393
4403
  },
@@ -4468,11 +4478,13 @@ export const schemaDict = {
4468
4478
  properties: {
4469
4479
  thumb: {
4470
4480
  type: 'string',
4481
+ format: 'uri',
4471
4482
  description:
4472
4483
  'Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.',
4473
4484
  },
4474
4485
  fullsize: {
4475
4486
  type: 'string',
4487
+ format: 'uri',
4476
4488
  description:
4477
4489
  'Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.',
4478
4490
  },
@@ -4886,6 +4898,7 @@ export const schemaDict = {
4886
4898
  },
4887
4899
  avatar: {
4888
4900
  type: 'string',
4901
+ format: 'uri',
4889
4902
  },
4890
4903
  likeCount: {
4891
4904
  type: 'integer',
@@ -6201,6 +6214,7 @@ export const schemaDict = {
6201
6214
  },
6202
6215
  avatar: {
6203
6216
  type: 'string',
6217
+ format: 'uri',
6204
6218
  },
6205
6219
  labels: {
6206
6220
  type: 'array',
@@ -6258,6 +6272,7 @@ export const schemaDict = {
6258
6272
  },
6259
6273
  avatar: {
6260
6274
  type: 'string',
6275
+ format: 'uri',
6261
6276
  },
6262
6277
  labels: {
6263
6278
  type: 'array',
@@ -15,7 +15,7 @@ export interface QueryParams {
15
15
  collection: string
16
16
  /** Record Key */
17
17
  rkey: string
18
- /** An optional past commit CID. */
18
+ /** DEPRECATED: referenced a repo commit by CID, and retrieved record as of that commit */
19
19
  commit?: string
20
20
  }
21
21
 
package/tests/_util.ts CHANGED
@@ -178,16 +178,16 @@ export const stripViewerFromPost = (postUnknown: unknown): PostView => {
178
178
  post.embed && isViewRecord(post.embed.record)
179
179
  ? post.embed.record // Record from record embed
180
180
  : post.embed?.['record'] && isViewRecord(post.embed['record']['record'])
181
- ? post.embed['record']['record'] // Record from record-with-media embed
182
- : undefined
181
+ ? post.embed['record']['record'] // Record from record-with-media embed
182
+ : undefined
183
183
  if (recordEmbed) {
184
184
  recordEmbed.author = stripViewer(recordEmbed.author)
185
185
  recordEmbed.embeds?.forEach((deepEmbed) => {
186
186
  const deepRecordEmbed = isViewRecord(deepEmbed.record)
187
187
  ? deepEmbed.record // Record from record embed
188
188
  : deepEmbed['record'] && isViewRecord(deepEmbed['record']['record'])
189
- ? deepEmbed['record']['record'] // Record from record-with-media embed
190
- : undefined
189
+ ? deepEmbed['record']['record'] // Record from record-with-media embed
190
+ : undefined
191
191
  if (deepRecordEmbed) {
192
192
  deepRecordEmbed.author = stripViewer(deepRecordEmbed.author)
193
193
  }
@@ -1,9 +1,10 @@
1
- import AtpAgent from '@atproto/api'
1
+ import AtpAgent, { AtUri } from '@atproto/api'
2
2
  import { TestNetwork, SeedClient, authorFeedSeed } from '@atproto/dev-env'
3
3
  import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util'
4
- import { isRecord } from '../../src/lexicon/types/app/bsky/feed/post'
4
+ import { ReplyRef, isRecord } from '../../src/lexicon/types/app/bsky/feed/post'
5
5
  import { isView as isEmbedRecordWithMedia } from '../../src/lexicon/types/app/bsky/embed/recordWithMedia'
6
6
  import { isView as isImageEmbed } from '../../src/lexicon/types/app/bsky/embed/images'
7
+ import { isPostView } from '../../src/lexicon/types/app/bsky/feed/defs'
7
8
 
8
9
  describe('pds author feed views', () => {
9
10
  let network: TestNetwork
@@ -282,13 +283,48 @@ describe('pds author feed views', () => {
282
283
  filter: 'posts_and_author_threads',
283
284
  })
284
285
 
285
- expect(eveFeed.feed.length).toEqual(7)
286
+ expect(eveFeed.feed.length).toEqual(6)
286
287
  expect(
287
288
  eveFeed.feed.some(({ post }) => {
288
- return (
289
+ const replyByEve =
289
290
  isRecord(post.record) && post.record.reply && post.author.did === eve
290
- )
291
+ return replyByEve
292
+ }),
293
+ ).toBeTruthy()
294
+ // does not include eve's replies to fred, even within her own thread.
295
+ expect(
296
+ eveFeed.feed.every(({ post, reply }) => {
297
+ if (!post || !isRecord(post.record) || !post.record.reply) {
298
+ return true // not a reply
299
+ }
300
+ const replyToEve = isReplyTo(post.record.reply, eve)
301
+ const replyToReplyByEve =
302
+ reply &&
303
+ isPostView(reply.parent) &&
304
+ isRecord(reply.parent.record) &&
305
+ (!reply.parent.record.reply ||
306
+ isReplyTo(reply.parent.record.reply, eve))
307
+ return replyToEve && replyToReplyByEve
308
+ }),
309
+ ).toBeTruthy()
310
+ // reposts are preserved
311
+ expect(
312
+ eveFeed.feed.some(({ post, reason }) => {
313
+ const repostOfOther =
314
+ reason && isRecord(post.record) && post.author.did !== eve
315
+ return repostOfOther
291
316
  }),
292
317
  ).toBeTruthy()
293
318
  })
294
319
  })
320
+
321
+ function isReplyTo(reply: ReplyRef, did: string) {
322
+ return (
323
+ getDidFromUri(reply.root.uri) === did &&
324
+ getDidFromUri(reply.parent.uri) === did
325
+ )
326
+ }
327
+
328
+ function getDidFromUri(uri: string) {
329
+ return new AtUri(uri).hostname
330
+ }