@atproto/api 0.10.1 → 0.10.3

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.
package/dist/util.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function sanitizeMutedWordValue(value: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/api",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "license": "MIT",
5
5
  "description": "Client library for atproto and Bluesky",
6
6
  "keywords": [
@@ -21,14 +21,14 @@
21
21
  "typed-emitter": "^2.1.0",
22
22
  "zod": "^3.21.4",
23
23
  "@atproto/common-web": "^0.2.3",
24
- "@atproto/lexicon": "^0.3.1",
25
- "@atproto/syntax": "^0.1.5",
26
- "@atproto/xrpc": "^0.4.1"
24
+ "@atproto/lexicon": "^0.3.2",
25
+ "@atproto/syntax": "^0.2.0",
26
+ "@atproto/xrpc": "^0.4.2"
27
27
  },
28
28
  "devDependencies": {
29
29
  "common-tags": "^1.8.2",
30
- "@atproto/lex-cli": "^0.3.0",
31
- "@atproto/dev-env": "^0.2.33"
30
+ "@atproto/lex-cli": "^0.3.1",
31
+ "@atproto/dev-env": "^0.2.35"
32
32
  },
33
33
  "scripts": {
34
34
  "codegen": "pnpm docgen && node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*",
package/src/bsky-agent.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  BskyThreadViewPreference,
14
14
  BskyInterestsPreference,
15
15
  } from './types'
16
+ import { sanitizeMutedWordValue } from './util'
16
17
 
17
18
  const FEED_VIEW_PREF_DEFAULTS = {
18
19
  hideReplies: false,
@@ -565,16 +566,108 @@ export class BskyAgent extends AtpAgent {
565
566
  })
566
567
  }
567
568
 
568
- async upsertMutedWords(mutedWords: AppBskyActorDefs.MutedWord[]) {
569
- await updateMutedWords(this, mutedWords, 'upsert')
569
+ async upsertMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) {
570
+ await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
571
+ let mutedWordsPref = prefs.findLast(
572
+ (pref) =>
573
+ AppBskyActorDefs.isMutedWordsPref(pref) &&
574
+ AppBskyActorDefs.validateMutedWordsPref(pref).success,
575
+ )
576
+
577
+ if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
578
+ for (const updatedWord of newMutedWords) {
579
+ let foundMatch = false
580
+ const sanitizedUpdatedValue = sanitizeMutedWordValue(
581
+ updatedWord.value,
582
+ )
583
+
584
+ // was trimmed down to an empty string e.g. single `#`
585
+ if (!sanitizedUpdatedValue) continue
586
+
587
+ for (const existingItem of mutedWordsPref.items) {
588
+ if (existingItem.value === sanitizedUpdatedValue) {
589
+ existingItem.targets = Array.from(
590
+ new Set([...existingItem.targets, ...updatedWord.targets]),
591
+ )
592
+ foundMatch = true
593
+ break
594
+ }
595
+ }
596
+
597
+ if (!foundMatch) {
598
+ mutedWordsPref.items.push({
599
+ ...updatedWord,
600
+ value: sanitizedUpdatedValue,
601
+ })
602
+ }
603
+ }
604
+ } else {
605
+ // if the pref doesn't exist, create it
606
+ mutedWordsPref = {
607
+ items: newMutedWords.map((w) => ({
608
+ ...w,
609
+ value: sanitizeMutedWordValue(w.value),
610
+ })),
611
+ }
612
+ }
613
+
614
+ return prefs
615
+ .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
616
+ .concat([
617
+ { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' },
618
+ ])
619
+ })
570
620
  }
571
621
 
572
622
  async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
573
- await updateMutedWords(this, [mutedWord], 'update')
623
+ await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
624
+ let mutedWordsPref = prefs.findLast(
625
+ (pref) =>
626
+ AppBskyActorDefs.isMutedWordsPref(pref) &&
627
+ AppBskyActorDefs.validateMutedWordsPref(pref).success,
628
+ )
629
+
630
+ if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
631
+ for (const existingItem of mutedWordsPref.items) {
632
+ if (existingItem.value === mutedWord.value) {
633
+ existingItem.targets = mutedWord.targets
634
+ break
635
+ }
636
+ }
637
+ }
638
+
639
+ return prefs
640
+ .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
641
+ .concat([
642
+ { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' },
643
+ ])
644
+ })
574
645
  }
575
646
 
576
647
  async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
577
- await updateMutedWords(this, [mutedWord], 'remove')
648
+ await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
649
+ let mutedWordsPref = prefs.findLast(
650
+ (pref) =>
651
+ AppBskyActorDefs.isMutedWordsPref(pref) &&
652
+ AppBskyActorDefs.validateMutedWordsPref(pref).success,
653
+ )
654
+
655
+ if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
656
+ for (let i = 0; i < mutedWordsPref.items.length; i++) {
657
+ const existing = mutedWordsPref.items[i]
658
+ if (existing.value === mutedWord.value) {
659
+ mutedWordsPref.items.splice(i, 1)
660
+ break
661
+ }
662
+ }
663
+ }
664
+
665
+ return prefs
666
+ .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
667
+ .concat([
668
+ { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' },
669
+ ])
670
+ })
578
671
  }
579
672
 
580
673
  async hidePost(postUri: string) {
@@ -646,76 +739,6 @@ async function updateFeedPreferences(
646
739
  return res
647
740
  }
648
741
 
649
- /**
650
- * A helper specifically for updating muted words preferences
651
- */
652
- async function updateMutedWords(
653
- agent: BskyAgent,
654
- mutedWords: AppBskyActorDefs.MutedWord[],
655
- action: 'upsert' | 'update' | 'remove',
656
- ) {
657
- const sanitizeMutedWord = (word: AppBskyActorDefs.MutedWord) => ({
658
- value: word.value.replace(/^#/, ''),
659
- targets: word.targets,
660
- })
661
-
662
- await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => {
663
- let mutedWordsPref = prefs.findLast(
664
- (pref) =>
665
- AppBskyActorDefs.isMutedWordsPref(pref) &&
666
- AppBskyActorDefs.validateMutedWordsPref(pref).success,
667
- )
668
-
669
- if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
670
- if (action === 'upsert' || action === 'update') {
671
- for (const newItem of mutedWords) {
672
- let foundMatch = false
673
-
674
- for (const existingItem of mutedWordsPref.items) {
675
- if (existingItem.value === newItem.value) {
676
- existingItem.targets =
677
- action === 'upsert'
678
- ? Array.from(
679
- new Set([...existingItem.targets, ...newItem.targets]),
680
- )
681
- : newItem.targets
682
- foundMatch = true
683
- break
684
- }
685
- }
686
-
687
- if (action === 'upsert' && !foundMatch) {
688
- mutedWordsPref.items.push(sanitizeMutedWord(newItem))
689
- }
690
- }
691
- } else if (action === 'remove') {
692
- for (const word of mutedWords) {
693
- for (let i = 0; i < mutedWordsPref.items.length; i++) {
694
- const existing = mutedWordsPref.items[i]
695
- if (existing.value === sanitizeMutedWord(word).value) {
696
- mutedWordsPref.items.splice(i, 1)
697
- break
698
- }
699
- }
700
- }
701
- }
702
- } else {
703
- // if the pref doesn't exist, create it
704
- if (action === 'upsert') {
705
- mutedWordsPref = {
706
- items: mutedWords.map(sanitizeMutedWord),
707
- }
708
- }
709
- }
710
-
711
- return prefs
712
- .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
713
- .concat([
714
- { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' },
715
- ])
716
- })
717
- }
718
-
719
742
  async function updateHiddenPost(
720
743
  agent: BskyAgent,
721
744
  postUri: string,
@@ -4867,7 +4867,7 @@ export const schemaDict = {
4867
4867
  main: {
4868
4868
  type: 'query',
4869
4869
  description:
4870
- 'Fetch all labels from a labeler created after a certain date. DEPRECATED: use queryLabels or subscribeLabels instead',
4870
+ 'DEPRECATED: use queryLabels or subscribeLabels instead -- Fetch all labels from a labeler created after a certain date.',
4871
4871
  parameters: {
4872
4872
  type: 'params',
4873
4873
  properties: {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  } from '@atproto/lexicon'
9
9
  export { parseLanguage } from '@atproto/common-web'
10
10
  export * from './types'
11
+ export * from './util'
11
12
  export * from './client'
12
13
  export * from './agent'
13
14
  export * from './rich-text/rich-text'
@@ -70,27 +70,25 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined {
70
70
  }
71
71
  }
72
72
  {
73
- const re = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
73
+ const re = /(^|\s)#((?!\ufe0f)[^\d\s]\S*)(?=\s)?/g
74
74
  while ((match = re.exec(text.utf16))) {
75
- let [tag] = match
76
- const hasLeadingSpace = /^\s/.test(tag)
75
+ let [, leading, tag] = match
77
76
 
78
77
  tag = tag.trim().replace(/\p{P}+$/gu, '') // strip ending punctuation
79
78
 
80
- // inclusive of #, max of 64 chars
81
- if (tag.length > 66) continue
79
+ if (tag.length === 0 || tag.length > 64) continue
82
80
 
83
- const index = match.index + (hasLeadingSpace ? 1 : 0)
81
+ const index = match.index + leading.length
84
82
 
85
83
  facets.push({
86
84
  index: {
87
85
  byteStart: text.utf16IndexToUtf8Index(index),
88
- byteEnd: text.utf16IndexToUtf8Index(index + tag.length), // inclusive of last char
86
+ byteEnd: text.utf16IndexToUtf8Index(index + 1 + tag.length),
89
87
  },
90
88
  features: [
91
89
  {
92
90
  $type: 'app.bsky.richtext.facet#tag',
93
- tag: tag.replace(/^#/, ''),
91
+ tag: tag,
94
92
  },
95
93
  ],
96
94
  })
package/src/util.ts ADDED
@@ -0,0 +1,6 @@
1
+ export function sanitizeMutedWordValue(value: string) {
2
+ return value
3
+ .trim()
4
+ .replace(/^#(?!\ufe0f)/, '')
5
+ .replace(/[\r\n\u00AD\u2060\u200D\u200C\u200B]+/, '')
6
+ }
@@ -1199,10 +1199,22 @@ describe('agent', () => {
1199
1199
  })
1200
1200
 
1201
1201
  it('upsertMutedWords with #', async () => {
1202
+ await agent.upsertMutedWords([
1203
+ { value: 'hashtag', targets: ['content'] },
1204
+ ])
1205
+ // is sanitized to `hashtag`
1202
1206
  await agent.upsertMutedWords([{ value: '#hashtag', targets: ['tag'] }])
1207
+
1203
1208
  const { mutedWords } = await agent.getPreferences()
1209
+
1204
1210
  expect(mutedWords.find((m) => m.value === '#hashtag')).toBeFalsy()
1205
- expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy()
1211
+ // merged with existing
1212
+ expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({
1213
+ value: 'hashtag',
1214
+ targets: ['content', 'tag'],
1215
+ })
1216
+ // only one added
1217
+ expect(mutedWords.filter((m) => m.value === 'hashtag').length).toBe(1)
1206
1218
  })
1207
1219
 
1208
1220
  it('updateMutedWord', async () => {
@@ -1230,15 +1242,21 @@ describe('agent', () => {
1230
1242
  expect(mutedWords.find((m) => m.value === 'no_exist')).toBeFalsy()
1231
1243
  })
1232
1244
 
1233
- it('updateMutedWord with #', async () => {
1245
+ it('updateMutedWord with #, does not update', async () => {
1246
+ await agent.upsertMutedWords([
1247
+ {
1248
+ value: '#just_a_tag',
1249
+ targets: ['tag'],
1250
+ },
1251
+ ])
1234
1252
  await agent.updateMutedWord({
1235
- value: 'hashtag',
1253
+ value: '#just_a_tag',
1236
1254
  targets: ['tag', 'content'],
1237
1255
  })
1238
1256
  const { mutedWords } = await agent.getPreferences()
1239
- expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({
1240
- value: 'hashtag',
1241
- targets: ['tag', 'content'],
1257
+ expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({
1258
+ value: 'just_a_tag',
1259
+ targets: ['tag'],
1242
1260
  })
1243
1261
  })
1244
1262
 
@@ -1255,11 +1273,124 @@ describe('agent', () => {
1255
1273
  expect(mutedWords.find((m) => m.value === 'tag_then_none')).toBeFalsy()
1256
1274
  })
1257
1275
 
1258
- it('removeMutedWord with #', async () => {
1276
+ it('removeMutedWord with #, no match, no removal', async () => {
1259
1277
  await agent.removeMutedWord({ value: '#hashtag', targets: [] })
1260
1278
  const { mutedWords } = await agent.getPreferences()
1261
1279
 
1262
- expect(mutedWords.find((m) => m.value === 'hashtag')).toBeFalsy()
1280
+ // was inserted with #hashtag, but we don't sanitize on remove
1281
+ expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy()
1282
+ })
1283
+
1284
+ it('single-hash #', async () => {
1285
+ const prev = await agent.getPreferences()
1286
+ const length = prev.mutedWords.length
1287
+ await agent.upsertMutedWords([{ value: '#', targets: [] }])
1288
+ const end = await agent.getPreferences()
1289
+
1290
+ // sanitized to empty string, not inserted
1291
+ expect(end.mutedWords.length).toEqual(length)
1292
+ })
1293
+
1294
+ it('multi-hash ##', async () => {
1295
+ await agent.upsertMutedWords([{ value: '##', targets: [] }])
1296
+ const { mutedWords } = await agent.getPreferences()
1297
+
1298
+ expect(mutedWords.find((m) => m.value === '#')).toBeTruthy()
1299
+ })
1300
+
1301
+ it('multi-hash ##hashtag', async () => {
1302
+ await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }])
1303
+ const a = await agent.getPreferences()
1304
+
1305
+ expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy()
1306
+
1307
+ await agent.removeMutedWord({ value: '#hashtag', targets: [] })
1308
+ const b = await agent.getPreferences()
1309
+
1310
+ expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy()
1311
+ })
1312
+
1313
+ it('hash emoji #️⃣', async () => {
1314
+ await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }])
1315
+ const { mutedWords } = await agent.getPreferences()
1316
+
1317
+ expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
1318
+
1319
+ await agent.removeMutedWord({ value: '#️⃣', targets: [] })
1320
+ const end = await agent.getPreferences()
1321
+
1322
+ expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
1323
+ })
1324
+
1325
+ it('hash emoji ##️⃣', async () => {
1326
+ await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }])
1327
+ const { mutedWords } = await agent.getPreferences()
1328
+
1329
+ expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
1330
+
1331
+ await agent.removeMutedWord({ value: '#️⃣', targets: [] })
1332
+ const end = await agent.getPreferences()
1333
+
1334
+ expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
1335
+ })
1336
+
1337
+ it('hash emoji ###️⃣', async () => {
1338
+ await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }])
1339
+ const { mutedWords } = await agent.getPreferences()
1340
+
1341
+ expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy()
1342
+
1343
+ await agent.removeMutedWord({ value: '##️⃣', targets: [] })
1344
+ const end = await agent.getPreferences()
1345
+
1346
+ expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy()
1347
+ })
1348
+
1349
+ describe(`invalid characters`, () => {
1350
+ it('zero width space', async () => {
1351
+ const prev = await agent.getPreferences()
1352
+ const length = prev.mutedWords.length
1353
+ await agent.upsertMutedWords([{ value: '#​', targets: [] }])
1354
+ const { mutedWords } = await agent.getPreferences()
1355
+
1356
+ expect(mutedWords.length).toEqual(length)
1357
+ })
1358
+
1359
+ it('newline', async () => {
1360
+ await agent.upsertMutedWords([
1361
+ { value: 'test value\n with newline', targets: [] },
1362
+ ])
1363
+ const { mutedWords } = await agent.getPreferences()
1364
+
1365
+ expect(
1366
+ mutedWords.find((m) => m.value === 'test value with newline'),
1367
+ ).toBeTruthy()
1368
+ })
1369
+
1370
+ it('newline(s)', async () => {
1371
+ await agent.upsertMutedWords([
1372
+ { value: 'test value\n\r with newline', targets: [] },
1373
+ ])
1374
+ const { mutedWords } = await agent.getPreferences()
1375
+
1376
+ expect(
1377
+ mutedWords.find((m) => m.value === 'test value with newline'),
1378
+ ).toBeTruthy()
1379
+ })
1380
+
1381
+ it('empty space', async () => {
1382
+ await agent.upsertMutedWords([{ value: ' ', targets: [] }])
1383
+ const { mutedWords } = await agent.getPreferences()
1384
+
1385
+ expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy()
1386
+ })
1387
+
1388
+ it('leading/trailing space', async () => {
1389
+ await agent.upsertMutedWords([{ value: ' trim ', targets: [] }])
1390
+ const { mutedWords } = await agent.getPreferences()
1391
+
1392
+ expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy()
1393
+ })
1263
1394
  })
1264
1395
  })
1265
1396
 
@@ -241,15 +241,16 @@ describe('detectFacets', () => {
241
241
  ['body #1', [], []],
242
242
  ['body #a1', ['a1'], [{ byteStart: 5, byteEnd: 8 }]],
243
243
  ['#', [], []],
244
+ ['#?', [], []],
244
245
  ['text #', [], []],
245
246
  ['text # text', [], []],
246
247
  [
247
- 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
248
- ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
249
- [{ byteStart: 5, byteEnd: 71 }],
248
+ 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
249
+ ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
250
+ [{ byteStart: 5, byteEnd: 70 }],
250
251
  ],
251
252
  [
252
- 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
253
+ 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
253
254
  [],
254
255
  [],
255
256
  ],
@@ -297,6 +298,27 @@ describe('detectFacets', () => {
297
298
  { byteStart: 17, byteEnd: 22 },
298
299
  ],
299
300
  ],
301
+ ['this #️⃣tag should not be a tag', [], []],
302
+ [
303
+ 'this ##️⃣tag should be a tag',
304
+ ['#️⃣tag'],
305
+ [
306
+ {
307
+ byteStart: 5,
308
+ byteEnd: 16,
309
+ },
310
+ ],
311
+ ],
312
+ [
313
+ 'this #t\nag should be a tag',
314
+ ['t'],
315
+ [
316
+ {
317
+ byteStart: 5,
318
+ byteEnd: 7,
319
+ },
320
+ ],
321
+ ],
300
322
  ]
301
323
 
302
324
  for (const [input, tags, indices] of inputs) {