@atproto/bsky 0.0.21 → 0.0.22

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 (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/api/com/atproto/moderation/util.d.ts +1 -1
  3. package/dist/db/index.js +17 -1
  4. package/dist/db/index.js.map +3 -3
  5. package/dist/db/migrations/20231213T181744386Z-moderation-subject-appeal.d.ts +3 -0
  6. package/dist/db/migrations/index.d.ts +1 -0
  7. package/dist/db/tables/moderation.d.ts +3 -1
  8. package/dist/index.js +213 -32
  9. package/dist/index.js.map +3 -3
  10. package/dist/lexicon/index.d.ts +1 -0
  11. package/dist/lexicon/lexicons.d.ts +35 -0
  12. package/dist/lexicon/types/com/atproto/admin/defs.d.ts +10 -1
  13. package/dist/lexicon/types/com/atproto/admin/queryModerationStatuses.d.ts +1 -0
  14. package/dist/lexicon/types/com/atproto/admin/sendEmail.d.ts +1 -0
  15. package/dist/lexicon/types/com/atproto/moderation/defs.d.ts +2 -1
  16. package/dist/services/feed/views.d.ts +1 -1
  17. package/dist/services/moderation/index.d.ts +7 -2
  18. package/package.json +5 -5
  19. package/src/api/app/bsky/feed/getPostThread.ts +4 -3
  20. package/src/api/app/bsky/feed/getTimeline.ts +54 -0
  21. package/src/api/com/atproto/admin/queryModerationStatuses.ts +2 -0
  22. package/src/api/com/atproto/moderation/createReport.ts +14 -3
  23. package/src/api/com/atproto/moderation/util.ts +2 -0
  24. package/src/api/health.ts +14 -0
  25. package/src/auto-moderator/index.ts +19 -0
  26. package/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts +23 -0
  27. package/src/db/migrations/index.ts +1 -0
  28. package/src/db/tables/moderation.ts +3 -0
  29. package/src/lexicon/index.ts +1 -0
  30. package/src/lexicon/lexicons.ts +40 -0
  31. package/src/lexicon/types/com/atproto/admin/defs.ts +28 -0
  32. package/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +2 -0
  33. package/src/lexicon/types/com/atproto/admin/sendEmail.ts +2 -0
  34. package/src/lexicon/types/com/atproto/moderation/defs.ts +3 -0
  35. package/src/services/feed/views.ts +6 -3
  36. package/src/services/moderation/index.ts +9 -0
  37. package/src/services/moderation/status.ts +24 -0
  38. package/src/services/moderation/views.ts +2 -0
  39. package/tests/admin/moderation-appeals.test.ts +269 -0
  40. package/tests/auto-moderator/labeler.test.ts +39 -0
  41. package/tests/views/blocks.test.ts +14 -1
  42. package/tests/views/timeline.test.ts +14 -0
@@ -32,6 +32,8 @@ export interface QueryParams {
32
32
  sortDirection: 'asc' | 'desc'
33
33
  /** Get subjects that were taken down */
34
34
  takendown?: boolean
35
+ /** Get subjects in unresolved appealed status */
36
+ appealed?: boolean
35
37
  limit: number
36
38
  cursor?: string
37
39
  }
@@ -15,6 +15,8 @@ export interface InputSchema {
15
15
  content: string
16
16
  subject?: string
17
17
  senderDid: string
18
+ /** Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers */
19
+ comment?: string
18
20
  [k: string]: unknown
19
21
  }
20
22
 
@@ -13,6 +13,7 @@ export type ReasonType =
13
13
  | 'com.atproto.moderation.defs#reasonSexual'
14
14
  | 'com.atproto.moderation.defs#reasonRude'
15
15
  | 'com.atproto.moderation.defs#reasonOther'
16
+ | 'com.atproto.moderation.defs#reasonAppeal'
16
17
  | (string & {})
17
18
 
18
19
  /** Spam: frequent unwanted promotion, replies, mentions */
@@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
27
28
  export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
28
29
  /** Other: reports not falling under another report category */
29
30
  export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
31
+ /** Appeal: appeal a previously taken moderation action */
32
+ export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
@@ -132,8 +132,8 @@ export class FeedViews {
132
132
  lists,
133
133
  viewer,
134
134
  )
135
- // skip over not found & blocked posts
136
- if (!post || blocks[post.uri]?.reply) {
135
+ // skip over not found post
136
+ if (!post) {
137
137
  continue
138
138
  }
139
139
  const feedPost = { post }
@@ -159,6 +159,7 @@ export class FeedViews {
159
159
  ) {
160
160
  const replyParent = this.formatMaybePostView(
161
161
  item.replyParent,
162
+ item.uri,
162
163
  actors,
163
164
  posts,
164
165
  threadgates,
@@ -171,6 +172,7 @@ export class FeedViews {
171
172
  )
172
173
  const replyRoot = this.formatMaybePostView(
173
174
  item.replyRoot,
175
+ item.uri,
174
176
  actors,
175
177
  posts,
176
178
  threadgates,
@@ -291,6 +293,7 @@ export class FeedViews {
291
293
 
292
294
  formatMaybePostView(
293
295
  uri: string,
296
+ replyUri: string | null,
294
297
  actors: ActorInfoMap,
295
298
  posts: PostInfoMap,
296
299
  threadgates: ThreadgateInfoMap,
@@ -320,7 +323,7 @@ export class FeedViews {
320
323
  if (
321
324
  post.author.viewer?.blockedBy ||
322
325
  post.author.viewer?.blocking ||
323
- blocks[uri]?.reply
326
+ (replyUri !== null && blocks[replyUri]?.reply)
324
327
  ) {
325
328
  if (!opts?.usePostViewUnion) return
326
329
  return this.blockedPost(post)
@@ -539,6 +539,7 @@ export class ModerationService {
539
539
  cursor,
540
540
  limit = 50,
541
541
  takendown,
542
+ appealed,
542
543
  reviewState,
543
544
  reviewedAfter,
544
545
  reviewedBefore,
@@ -554,6 +555,7 @@ export class ModerationService {
554
555
  cursor?: string
555
556
  limit?: number
556
557
  takendown?: boolean
558
+ appealed?: boolean | null
557
559
  reviewedBefore?: string
558
560
  reviewState?: ModerationSubjectStatusRow['reviewState']
559
561
  reviewedAfter?: string
@@ -615,6 +617,13 @@ export class ModerationService {
615
617
  builder = builder.where('takendown', '=', true)
616
618
  }
617
619
 
620
+ if (appealed !== undefined) {
621
+ builder =
622
+ appealed === null
623
+ ? builder.where('appealed', 'is', null)
624
+ : builder.where('appealed', '=', appealed)
625
+ }
626
+
618
627
  if (!includeMuted) {
619
628
  builder = builder.where((qb) =>
620
629
  qb
@@ -12,6 +12,7 @@ import { ModerationEventRow, ModerationSubjectStatusRow } from './types'
12
12
  import { HOUR } from '@atproto/common'
13
13
  import { CID } from 'multiformats/cid'
14
14
  import { sql } from 'kysely'
15
+ import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
15
16
 
16
17
  const getSubjectStatusForModerationEvent = ({
17
18
  action,
@@ -82,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({
82
83
  lastReviewedBy: createdBy,
83
84
  lastReviewedAt: createdAt,
84
85
  }
86
+ case 'com.atproto.admin.defs#modEventResolveAppeal':
87
+ return {
88
+ appealed: false,
89
+ }
85
90
  default:
86
91
  return null
87
92
  }
@@ -106,6 +111,10 @@ export const adjustModerationSubjectStatus = async (
106
111
  createdAt,
107
112
  } = moderationEvent
108
113
 
114
+ const isAppealEvent =
115
+ action === 'com.atproto.admin.defs#modEventReport' &&
116
+ meta?.reportType === REASONAPPEAL
117
+
109
118
  const subjectStatus = getSubjectStatusForModerationEvent({
110
119
  action,
111
120
  createdBy,
@@ -162,6 +171,21 @@ export const adjustModerationSubjectStatus = async (
162
171
  subjectStatus.takendown = false
163
172
  }
164
173
 
174
+ if (isAppealEvent) {
175
+ newStatus.appealed = true
176
+ subjectStatus.appealed = true
177
+ newStatus.lastAppealedAt = createdAt
178
+ subjectStatus.lastAppealedAt = createdAt
179
+ }
180
+
181
+ if (
182
+ action === 'com.atproto.admin.defs#modEventResolveAppeal' &&
183
+ subjectStatus.appealed
184
+ ) {
185
+ newStatus.appealed = false
186
+ subjectStatus.appealed = false
187
+ }
188
+
165
189
  if (action === 'com.atproto.admin.defs#modEventComment' && meta?.sticky) {
166
190
  newStatus.comment = comment
167
191
  subjectStatus.comment = comment
@@ -485,9 +485,11 @@ export class ModerationViews {
485
485
  lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined,
486
486
  lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined,
487
487
  lastReportedAt: subjectStatus.lastReportedAt ?? undefined,
488
+ lastAppealedAt: subjectStatus.lastAppealedAt ?? undefined,
488
489
  muteUntil: subjectStatus.muteUntil ?? undefined,
489
490
  suspendUntil: subjectStatus.suspendUntil ?? undefined,
490
491
  takendown: subjectStatus.takendown ?? undefined,
492
+ appealed: subjectStatus.appealed ?? undefined,
491
493
  subjectRepoHandle: subjectStatus.handle ?? undefined,
492
494
  subjectBlobCids: subjectStatus.blobCids || [],
493
495
  subject: !subjectStatus.recordPath
@@ -0,0 +1,269 @@
1
+ import { TestNetwork, SeedClient } from '@atproto/dev-env'
2
+ import AtpAgent, {
3
+ ComAtprotoAdminDefs,
4
+ ComAtprotoAdminEmitModerationEvent,
5
+ ComAtprotoAdminQueryModerationStatuses,
6
+ } from '@atproto/api'
7
+ import basicSeed from '../seeds/basic'
8
+ import {
9
+ REASONMISLEADING,
10
+ REASONSPAM,
11
+ } from '../../src/lexicon/types/com/atproto/moderation/defs'
12
+ import {
13
+ REVIEWCLOSED,
14
+ REVIEWOPEN,
15
+ } from '@atproto/api/src/client/types/com/atproto/admin/defs'
16
+ import { REASONAPPEAL } from '@atproto/api/src/client/types/com/atproto/moderation/defs'
17
+ import { REVIEWESCALATED } from '../../src/lexicon/types/com/atproto/admin/defs'
18
+
19
+ describe('moderation-appeals', () => {
20
+ let network: TestNetwork
21
+ let agent: AtpAgent
22
+ let pdsAgent: AtpAgent
23
+ let sc: SeedClient
24
+
25
+ const emitModerationEvent = async (
26
+ eventData: ComAtprotoAdminEmitModerationEvent.InputSchema,
27
+ ) => {
28
+ return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, {
29
+ encoding: 'application/json',
30
+ headers: network.bsky.adminAuthHeaders('moderator'),
31
+ })
32
+ }
33
+
34
+ const queryModerationStatuses = (
35
+ statusQuery: ComAtprotoAdminQueryModerationStatuses.QueryParams,
36
+ ) =>
37
+ agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, {
38
+ headers: network.bsky.adminAuthHeaders('moderator'),
39
+ })
40
+
41
+ beforeAll(async () => {
42
+ network = await TestNetwork.create({
43
+ dbPostgresSchema: 'bsky_moderation_statuses',
44
+ })
45
+ agent = network.bsky.getClient()
46
+ pdsAgent = network.pds.getClient()
47
+ sc = network.getSeedClient()
48
+ await basicSeed(sc)
49
+ await network.processAll()
50
+ })
51
+
52
+ afterAll(async () => {
53
+ await network.close()
54
+ })
55
+
56
+ const assertSubjectStatus = async (
57
+ subject: string,
58
+ status: string,
59
+ appealed: boolean | undefined,
60
+ ): Promise<ComAtprotoAdminDefs.SubjectStatusView | undefined> => {
61
+ const { data } = await queryModerationStatuses({
62
+ subject,
63
+ })
64
+ expect(data.subjectStatuses[0]?.reviewState).toEqual(status)
65
+ expect(data.subjectStatuses[0]?.appealed).toEqual(appealed)
66
+ return data.subjectStatuses[0]
67
+ }
68
+ describe('appeals from users', () => {
69
+ const getBobsPostSubject = () => ({
70
+ $type: 'com.atproto.repo.strongRef',
71
+ uri: sc.posts[sc.dids.bob][1].ref.uriStr,
72
+ cid: sc.posts[sc.dids.bob][1].ref.cidStr,
73
+ })
74
+ const getCarolPostSubject = () => ({
75
+ $type: 'com.atproto.repo.strongRef',
76
+ uri: sc.posts[sc.dids.carol][0].ref.uriStr,
77
+ cid: sc.posts[sc.dids.carol][0].ref.cidStr,
78
+ })
79
+ const assertBobsPostStatus = async (
80
+ status: string,
81
+ appealed: boolean | undefined,
82
+ ) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed)
83
+
84
+ it('only changes subject status if original author of the content or a moderator is appealing', async () => {
85
+ // Create a report by alice
86
+ await emitModerationEvent({
87
+ event: {
88
+ $type: 'com.atproto.admin.defs#modEventReport',
89
+ reportType: REASONMISLEADING,
90
+ },
91
+ subject: getBobsPostSubject(),
92
+ createdBy: sc.dids.alice,
93
+ })
94
+
95
+ await assertBobsPostStatus(REVIEWOPEN, undefined)
96
+
97
+ // Create a report as normal user with appeal type
98
+ expect(
99
+ sc.createReport({
100
+ reportedBy: sc.dids.carol,
101
+ reasonType: REASONAPPEAL,
102
+ reason: 'appealing',
103
+ subject: getBobsPostSubject(),
104
+ }),
105
+ ).rejects.toThrow('You cannot appeal this report')
106
+
107
+ // Verify that the appeal status did not change
108
+ await assertBobsPostStatus(REVIEWOPEN, undefined)
109
+
110
+ // Emit report event as moderator
111
+ await emitModerationEvent({
112
+ event: {
113
+ $type: 'com.atproto.admin.defs#modEventReport',
114
+ reportType: REASONAPPEAL,
115
+ },
116
+ subject: getBobsPostSubject(),
117
+ createdBy: sc.dids.alice,
118
+ })
119
+
120
+ // Verify that appeal status changed when appeal report was emitted by moderator
121
+ const status = await assertBobsPostStatus(REVIEWOPEN, true)
122
+ expect(status?.appealedAt).not.toBeNull()
123
+
124
+ // Create a report as normal user for carol's post
125
+ await sc.createReport({
126
+ reportedBy: sc.dids.alice,
127
+ reasonType: REASONMISLEADING,
128
+ reason: 'lies!',
129
+ subject: getCarolPostSubject(),
130
+ })
131
+
132
+ // Verify that the appeal status on carol's post is undefined
133
+ await assertSubjectStatus(
134
+ getCarolPostSubject().uri,
135
+ REVIEWOPEN,
136
+ undefined,
137
+ )
138
+
139
+ await sc.createReport({
140
+ reportedBy: sc.dids.carol,
141
+ reasonType: REASONAPPEAL,
142
+ reason: 'appealing',
143
+ subject: getCarolPostSubject(),
144
+ })
145
+ // Verify that the appeal status on carol's post is true
146
+ await assertSubjectStatus(getCarolPostSubject().uri, REVIEWOPEN, true)
147
+ })
148
+ it('allows multiple appeals and updates last appealed timestamp', async () => {
149
+ // Resolve appeal with acknowledge
150
+ await emitModerationEvent({
151
+ event: {
152
+ $type: 'com.atproto.admin.defs#modEventResolveAppeal',
153
+ },
154
+ subject: getBobsPostSubject(),
155
+ createdBy: sc.dids.carol,
156
+ })
157
+
158
+ const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false)
159
+
160
+ await emitModerationEvent({
161
+ event: {
162
+ $type: 'com.atproto.admin.defs#modEventReport',
163
+ reportType: REASONAPPEAL,
164
+ },
165
+ subject: getBobsPostSubject(),
166
+ createdBy: sc.dids.bob,
167
+ })
168
+
169
+ // Verify that even after the appeal event by bob for his post, the appeal status is true again with new timestamp
170
+ const newStatus = await assertBobsPostStatus(REVIEWOPEN, true)
171
+ expect(
172
+ new Date(`${previousStatus?.lastAppealedAt}`).getTime(),
173
+ ).toBeLessThan(new Date(`${newStatus?.lastAppealedAt}`).getTime())
174
+ })
175
+ })
176
+
177
+ describe('appeal resolution', () => {
178
+ const getAlicesPostSubject = () => ({
179
+ $type: 'com.atproto.repo.strongRef',
180
+ uri: sc.posts[sc.dids.alice][1].ref.uriStr,
181
+ cid: sc.posts[sc.dids.alice][1].ref.cidStr,
182
+ })
183
+ it('appeal status is maintained while review state changes based on incoming events', async () => {
184
+ // Bob reports alice's post
185
+ await emitModerationEvent({
186
+ event: {
187
+ $type: 'com.atproto.admin.defs#modEventReport',
188
+ reportType: REASONMISLEADING,
189
+ },
190
+ subject: getAlicesPostSubject(),
191
+ createdBy: sc.dids.bob,
192
+ })
193
+
194
+ // Moderator acknowledges the report, assume a label was applied too
195
+ await emitModerationEvent({
196
+ event: {
197
+ $type: 'com.atproto.admin.defs#modEventAcknowledge',
198
+ },
199
+ subject: getAlicesPostSubject(),
200
+ createdBy: sc.dids.carol,
201
+ })
202
+
203
+ // Alice appeals the report
204
+ await emitModerationEvent({
205
+ event: {
206
+ $type: 'com.atproto.admin.defs#modEventReport',
207
+ reportType: REASONAPPEAL,
208
+ },
209
+ subject: getAlicesPostSubject(),
210
+ createdBy: sc.dids.alice,
211
+ })
212
+
213
+ await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
214
+
215
+ // Bob reports it again
216
+ await emitModerationEvent({
217
+ event: {
218
+ $type: 'com.atproto.admin.defs#modEventReport',
219
+ reportType: REASONSPAM,
220
+ },
221
+ subject: getAlicesPostSubject(),
222
+ createdBy: sc.dids.bob,
223
+ })
224
+
225
+ // Assert that the status is still REVIEWOPEN, as report events are meant to do
226
+ await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
227
+
228
+ // Emit an escalation event
229
+ await emitModerationEvent({
230
+ event: {
231
+ $type: 'com.atproto.admin.defs#modEventEscalate',
232
+ },
233
+ subject: getAlicesPostSubject(),
234
+ createdBy: sc.dids.carol,
235
+ })
236
+
237
+ await assertSubjectStatus(
238
+ getAlicesPostSubject().uri,
239
+ REVIEWESCALATED,
240
+ true,
241
+ )
242
+
243
+ // Emit an acknowledge event
244
+ await emitModerationEvent({
245
+ event: {
246
+ $type: 'com.atproto.admin.defs#modEventAcknowledge',
247
+ },
248
+ subject: getAlicesPostSubject(),
249
+ createdBy: sc.dids.carol,
250
+ })
251
+
252
+ // Assert that status moved on to reviewClosed while appealed status is still true
253
+ await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, true)
254
+
255
+ // Emit a resolveAppeal event
256
+ await emitModerationEvent({
257
+ event: {
258
+ $type: 'com.atproto.admin.defs#modEventResolveAppeal',
259
+ comment: 'lgtm',
260
+ },
261
+ subject: getAlicesPostSubject(),
262
+ createdBy: sc.dids.carol,
263
+ })
264
+
265
+ // Assert that status stayed the same while appealed status is still true
266
+ await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, false)
267
+ })
268
+ })
269
+ })
@@ -9,6 +9,8 @@ import { LabelService } from '../../src/services/label'
9
9
  import usersSeed from '../seeds/users'
10
10
  import { CID } from 'multiformats/cid'
11
11
  import { ImgLabeler } from '../../src/auto-moderator/hive'
12
+ import { ModerationService } from '../../src/services/moderation'
13
+ import { ImageInvalidator } from '../../src/image/invalidator'
12
14
 
13
15
  // outside of test suite so that TestLabeler can access them
14
16
  let badCid1: CID | undefined = undefined
@@ -75,6 +77,10 @@ describe('labeler', () => {
75
77
  })
76
78
 
77
79
  it('labels text in posts', async () => {
80
+ autoMod.services.moderation = ModerationService.creator(
81
+ new NoopImageUriBuilder(''),
82
+ new NoopInvalidator(),
83
+ )
78
84
  const post = {
79
85
  $type: 'app.bsky.feed.post',
80
86
  text: 'blah blah label_me',
@@ -93,6 +99,28 @@ describe('labeler', () => {
93
99
  val: 'test-label',
94
100
  neg: false,
95
101
  })
102
+
103
+ // Verify that along with applying the labels, we are also leaving trace of the label as moderation event
104
+ // Temporarily assign an instance of moderation service to the autoMod so that we can validate label event
105
+ const modSrvc = autoMod.services.moderation(ctx.db)
106
+ const { events } = await modSrvc.getEvents({
107
+ includeAllUserRecords: false,
108
+ subject: uri.toString(),
109
+ limit: 10,
110
+ types: [],
111
+ })
112
+ expect(events.length).toBe(1)
113
+ expect(events[0]).toMatchObject({
114
+ action: 'com.atproto.admin.defs#modEventLabel',
115
+ subjectUri: uri.toString(),
116
+ createLabelVals: 'test-label',
117
+ negateLabelVals: null,
118
+ comment: `[AutoModerator]: Applying labels`,
119
+ createdBy: labelerDid,
120
+ })
121
+
122
+ // Cleanup the temporary assignment, knowing that by default, moderation service is not available
123
+ autoMod.services.moderation = undefined
96
124
  })
97
125
 
98
126
  it('labels embeds in posts', async () => {
@@ -165,3 +193,14 @@ class TestImgLabeler implements ImgLabeler {
165
193
  return []
166
194
  }
167
195
  }
196
+
197
+ class NoopInvalidator implements ImageInvalidator {
198
+ async invalidate() {}
199
+ }
200
+ class NoopImageUriBuilder {
201
+ constructor(public endpoint: string) {}
202
+
203
+ getPresetUri() {
204
+ return ''
205
+ }
206
+ }
@@ -112,6 +112,18 @@ describe('pds views with blocking', () => {
112
112
  expect(forSnapshot(thread)).toMatchSnapshot()
113
113
  })
114
114
 
115
+ it('loads blocked reply as anchor with no parent', async () => {
116
+ const { data: thread } = await agent.api.app.bsky.feed.getPostThread(
117
+ { depth: 1, uri: carolReplyToDan.ref.uriStr },
118
+ { headers: await network.serviceHeaders(alice) },
119
+ )
120
+ if (!isThreadViewPost(thread.thread)) {
121
+ throw new Error('Expected thread view post')
122
+ }
123
+ expect(thread.thread.post.uri).toEqual(carolReplyToDan.ref.uriStr)
124
+ expect(thread.thread.parent).toBeUndefined()
125
+ })
126
+
115
127
  it('blocks thread parent', async () => {
116
128
  // Parent is a post by dan
117
129
  const { data: thread } = await agent.api.app.bsky.feed.getPostThread(
@@ -498,7 +510,8 @@ describe('pds views with blocking', () => {
498
510
  const replyBlockedPost = timeline.feed.find(
499
511
  (item) => item.post.uri === replyBlockedUri,
500
512
  )
501
- expect(replyBlockedPost).toBeUndefined()
513
+ assert(replyBlockedPost)
514
+ expect(replyBlockedPost.reply?.parent).toBeUndefined()
502
515
  const embedBlockedPost = timeline.feed.find(
503
516
  (item) => item.post.uri === embedBlockedUri,
504
517
  )
@@ -154,6 +154,20 @@ describe('timeline views', () => {
154
154
  expect(results(paginatedAll)).toEqual(results([full.data]))
155
155
  })
156
156
 
157
+ it('agrees what the first item is for limit=1 and other limits', async () => {
158
+ const { data: timeline } = await agent.api.app.bsky.feed.getTimeline(
159
+ { limit: 10 },
160
+ { headers: await network.serviceHeaders(alice) },
161
+ )
162
+ const { data: timelineLimit1 } = await agent.api.app.bsky.feed.getTimeline(
163
+ { limit: 1 },
164
+ { headers: await network.serviceHeaders(alice) },
165
+ )
166
+ expect(timeline.feed.length).toBeGreaterThan(1)
167
+ expect(timelineLimit1.feed.length).toEqual(1)
168
+ expect(timelineLimit1.feed[0].post.uri).toBe(timeline.feed[0].post.uri)
169
+ })
170
+
157
171
  it('reflects self-labels', async () => {
158
172
  const carolTL = await agent.api.app.bsky.feed.getTimeline(
159
173
  {},