@atproto/api 0.6.16 → 0.6.18

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.
@@ -4,6 +4,7 @@ import { UnicodeString } from './unicode';
4
4
  export declare type Facet = AppBskyRichtextFacet.Main;
5
5
  export declare type FacetLink = AppBskyRichtextFacet.Link;
6
6
  export declare type FacetMention = AppBskyRichtextFacet.Mention;
7
+ export declare type FacetTag = AppBskyRichtextFacet.Tag;
7
8
  export declare type Entity = AppBskyFeedPost.Entity;
8
9
  export interface RichTextProps {
9
10
  text: string;
@@ -21,6 +22,8 @@ export declare class RichTextSegment {
21
22
  isLink(): boolean;
22
23
  get mention(): FacetMention | undefined;
23
24
  isMention(): boolean;
25
+ get tag(): FacetTag | undefined;
26
+ isTag(): boolean;
24
27
  }
25
28
  export declare class RichText {
26
29
  unicodeText: UnicodeString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/api",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "license": "MIT",
5
5
  "description": "Client library for atproto and Bluesky",
6
6
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "devDependencies": {
28
28
  "common-tags": "^1.8.2",
29
29
  "@atproto/lex-cli": "^0.2.1",
30
- "@atproto/pds": "^0.1.16"
30
+ "@atproto/pds": "^0.1.18"
31
31
  },
32
32
  "scripts": {
33
33
  "codegen": "pnpm docgen && node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*",
@@ -5622,6 +5622,16 @@ export const schemaDict = {
5622
5622
  type: 'union',
5623
5623
  refs: ['lex:com.atproto.label.defs#selfLabels'],
5624
5624
  },
5625
+ tags: {
5626
+ type: 'array',
5627
+ maxLength: 8,
5628
+ items: {
5629
+ type: 'string',
5630
+ maxLength: 640,
5631
+ maxGraphemes: 64,
5632
+ },
5633
+ description: 'Additional non-inline tags describing this post.',
5634
+ },
5625
5635
  createdAt: {
5626
5636
  type: 'string',
5627
5637
  format: 'datetime',
@@ -6761,6 +6771,7 @@ export const schemaDict = {
6761
6771
  refs: [
6762
6772
  'lex:app.bsky.richtext.facet#mention',
6763
6773
  'lex:app.bsky.richtext.facet#link',
6774
+ 'lex:app.bsky.richtext.facet#tag',
6764
6775
  ],
6765
6776
  },
6766
6777
  },
@@ -6788,6 +6799,18 @@ export const schemaDict = {
6788
6799
  },
6789
6800
  },
6790
6801
  },
6802
+ tag: {
6803
+ type: 'object',
6804
+ description: 'A hashtag.',
6805
+ required: ['tag'],
6806
+ properties: {
6807
+ tag: {
6808
+ type: 'string',
6809
+ maxLength: 640,
6810
+ maxGraphemes: 64,
6811
+ },
6812
+ },
6813
+ },
6791
6814
  byteSlice: {
6792
6815
  type: 'object',
6793
6816
  description:
@@ -29,6 +29,8 @@ export interface Record {
29
29
  labels?:
30
30
  | ComAtprotoLabelDefs.SelfLabels
31
31
  | { $type: string; [k: string]: unknown }
32
+ /** Additional non-inline tags describing this post. */
33
+ tags?: string[]
32
34
  createdAt: string
33
35
  [k: string]: unknown
34
36
  }
@@ -8,7 +8,7 @@ import { CID } from 'multiformats/cid'
8
8
 
9
9
  export interface Main {
10
10
  index: ByteSlice
11
- features: (Mention | Link | { $type: string; [k: string]: unknown })[]
11
+ features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[]
12
12
  [k: string]: unknown
13
13
  }
14
14
 
@@ -61,6 +61,22 @@ export function validateLink(v: unknown): ValidationResult {
61
61
  return lexicons.validate('app.bsky.richtext.facet#link', v)
62
62
  }
63
63
 
64
+ /** A hashtag. */
65
+ export interface Tag {
66
+ tag: string
67
+ [k: string]: unknown
68
+ }
69
+
70
+ export function isTag(v: unknown): v is Tag {
71
+ return (
72
+ isObj(v) && hasProp(v, '$type') && v.$type === 'app.bsky.richtext.facet#tag'
73
+ )
74
+ }
75
+
76
+ export function validateTag(v: unknown): ValidationResult {
77
+ return lexicons.validate('app.bsky.richtext.facet#tag', v)
78
+ }
79
+
64
80
  /** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */
65
81
  export interface ByteSlice {
66
82
  byteStart: number
@@ -69,6 +69,33 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined {
69
69
  })
70
70
  }
71
71
  }
72
+ {
73
+ const re = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
74
+ while ((match = re.exec(text.utf16))) {
75
+ let [tag] = match
76
+ const hasLeadingSpace = /^\s/.test(tag)
77
+
78
+ tag = tag.trim().replace(/\p{P}+$/gu, '') // strip ending punctuation
79
+
80
+ // inclusive of #, max of 64 chars
81
+ if (tag.length > 66) continue
82
+
83
+ const index = match.index + (hasLeadingSpace ? 1 : 0)
84
+
85
+ facets.push({
86
+ index: {
87
+ byteStart: text.utf16IndexToUtf8Index(index),
88
+ byteEnd: text.utf16IndexToUtf8Index(index + tag.length), // inclusive of last char
89
+ },
90
+ features: [
91
+ {
92
+ $type: 'app.bsky.richtext.facet#tag',
93
+ tag,
94
+ },
95
+ ],
96
+ })
97
+ }
98
+ }
72
99
  return facets.length > 0 ? facets : undefined
73
100
  }
74
101
 
@@ -100,6 +100,7 @@ import { detectFacets } from './detection'
100
100
  export type Facet = AppBskyRichtextFacet.Main
101
101
  export type FacetLink = AppBskyRichtextFacet.Link
102
102
  export type FacetMention = AppBskyRichtextFacet.Mention
103
+ export type FacetTag = AppBskyRichtextFacet.Tag
103
104
  export type Entity = AppBskyFeedPost.Entity
104
105
 
105
106
  export interface RichTextProps {
@@ -141,6 +142,18 @@ export class RichTextSegment {
141
142
  isMention() {
142
143
  return !!this.mention
143
144
  }
145
+
146
+ get tag(): FacetTag | undefined {
147
+ const tag = this.facet?.features.find(AppBskyRichtextFacet.isTag)
148
+ if (AppBskyRichtextFacet.isTag(tag)) {
149
+ return tag
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ isTag() {
155
+ return !!this.tag
156
+ }
144
157
  }
145
158
 
146
159
  export class RichText {
@@ -197,7 +197,7 @@ describe('agent', () => {
197
197
 
198
198
  // put the agent through the auth flow
199
199
  AtpAgent.configure({ fetch: tokenExpiredFetchHandler })
200
- const res1 = await agent.api.app.bsky.feed.getTimeline()
200
+ const res1 = await createPost(agent)
201
201
  AtpAgent.configure({ fetch: defaultFetchHandler })
202
202
 
203
203
  expect(res1.success).toEqual(true)
@@ -267,9 +267,9 @@ describe('agent', () => {
267
267
  // put the agent through the auth flow
268
268
  AtpAgent.configure({ fetch: tokenExpiredFetchHandler })
269
269
  const [res1, res2, res3] = await Promise.all([
270
- agent.api.app.bsky.feed.getTimeline(),
271
- agent.api.app.bsky.feed.getTimeline(),
272
- agent.api.app.bsky.feed.getTimeline(),
270
+ createPost(agent),
271
+ createPost(agent),
272
+ createPost(agent),
273
273
  ])
274
274
  AtpAgent.configure({ fetch: defaultFetchHandler })
275
275
 
@@ -462,3 +462,14 @@ describe('agent', () => {
462
462
  })
463
463
  })
464
464
  })
465
+
466
+ const createPost = async (agent: AtpAgent) => {
467
+ return agent.api.com.atproto.repo.createRecord({
468
+ repo: agent.session?.did ?? '',
469
+ collection: 'app.bsky.feed.post',
470
+ record: {
471
+ text: 'hello there',
472
+ createdAt: new Date().toISOString(),
473
+ },
474
+ })
475
+ }
@@ -20,6 +20,20 @@ describe('agent', () => {
20
20
  await close()
21
21
  })
22
22
 
23
+ const getProfileDisplayName = async (
24
+ agent: BskyAgent,
25
+ ): Promise<string | undefined> => {
26
+ try {
27
+ const res = await agent.api.app.bsky.actor.profile.get({
28
+ repo: agent.session?.did || '',
29
+ rkey: 'self',
30
+ })
31
+ return res.value.displayName ?? ''
32
+ } catch (err) {
33
+ return undefined
34
+ }
35
+ }
36
+
23
37
  it('upsertProfile correctly creates and updates profiles.', async () => {
24
38
  const agent = new BskyAgent({ service: server.url })
25
39
 
@@ -28,9 +42,8 @@ describe('agent', () => {
28
42
  email: 'user1@test.com',
29
43
  password: 'password',
30
44
  })
31
-
32
- const profile1 = await agent.getProfile({ actor: agent.session?.did || '' })
33
- expect(profile1.data.displayName).toBeFalsy()
45
+ const displayName1 = await getProfileDisplayName(agent)
46
+ expect(displayName1).toBeFalsy()
34
47
 
35
48
  await agent.upsertProfile((existing) => {
36
49
  expect(existing).toBeFalsy()
@@ -39,8 +52,8 @@ describe('agent', () => {
39
52
  }
40
53
  })
41
54
 
42
- const profile2 = await agent.getProfile({ actor: agent.session?.did || '' })
43
- expect(profile2.data.displayName).toBe('Bob')
55
+ const displayName2 = await getProfileDisplayName(agent)
56
+ expect(displayName2).toBe('Bob')
44
57
 
45
58
  await agent.upsertProfile((existing) => {
46
59
  expect(existing).toBeTruthy()
@@ -49,8 +62,8 @@ describe('agent', () => {
49
62
  }
50
63
  })
51
64
 
52
- const profile3 = await agent.getProfile({ actor: agent.session?.did || '' })
53
- expect(profile3.data.displayName).toBe('BOB')
65
+ const displayName3 = await getProfileDisplayName(agent)
66
+ expect(displayName3).toBe('BOB')
54
67
  })
55
68
 
56
69
  it('upsertProfile correctly handles CAS failures.', async () => {
@@ -62,8 +75,8 @@ describe('agent', () => {
62
75
  password: 'password',
63
76
  })
64
77
 
65
- const profile1 = await agent.getProfile({ actor: agent.session?.did || '' })
66
- expect(profile1.data.displayName).toBeFalsy()
78
+ const displayName1 = await getProfileDisplayName(agent)
79
+ expect(displayName1).toBeFalsy()
67
80
 
68
81
  let hasConflicted = false
69
82
  let ranTwice = false
@@ -88,8 +101,8 @@ describe('agent', () => {
88
101
  })
89
102
  expect(ranTwice).toBe(true)
90
103
 
91
- const profile2 = await agent.getProfile({ actor: agent.session?.did || '' })
92
- expect(profile2.data.displayName).toBe('Bob')
104
+ const displayName2 = await getProfileDisplayName(agent)
105
+ expect(displayName2).toBe('Bob')
93
106
  })
94
107
 
95
108
  it('upsertProfile wont endlessly retry CAS failures.', async () => {
@@ -101,8 +114,8 @@ describe('agent', () => {
101
114
  password: 'password',
102
115
  })
103
116
 
104
- const profile1 = await agent.getProfile({ actor: agent.session?.did || '' })
105
- expect(profile1.data.displayName).toBeFalsy()
117
+ const displayName1 = await getProfileDisplayName(agent)
118
+ expect(displayName1).toBeFalsy()
106
119
 
107
120
  const p = agent.upsertProfile(async (_existing) => {
108
121
  await agent.com.atproto.repo.putRecord({
@@ -1,4 +1,5 @@
1
1
  import { AtpAgent, RichText, RichTextSegment } from '../src'
2
+ import { isTag } from '../src/client/types/app/bsky/richtext/facet'
2
3
 
3
4
  describe('detectFacets', () => {
4
5
  const agent = new AtpAgent({ service: 'http://localhost' })
@@ -208,6 +209,109 @@ describe('detectFacets', () => {
208
209
  expect(Array.from(rt.segments(), segmentToOutput)).toEqual(outputs[i])
209
210
  }
210
211
  })
212
+
213
+ it('correctly detects tags inline', async () => {
214
+ const inputs: [
215
+ string,
216
+ string[],
217
+ { byteStart: number; byteEnd: number }[],
218
+ ][] = [
219
+ ['#a', ['#a'], [{ byteStart: 0, byteEnd: 2 }]],
220
+ [
221
+ '#a #b',
222
+ ['#a', '#b'],
223
+ [
224
+ { byteStart: 0, byteEnd: 2 },
225
+ { byteStart: 3, byteEnd: 5 },
226
+ ],
227
+ ],
228
+ ['#1', [], []],
229
+ ['#tag', ['#tag'], [{ byteStart: 0, byteEnd: 4 }]],
230
+ ['body #tag', ['#tag'], [{ byteStart: 5, byteEnd: 9 }]],
231
+ ['#tag body', ['#tag'], [{ byteStart: 0, byteEnd: 4 }]],
232
+ ['body #tag body', ['#tag'], [{ byteStart: 5, byteEnd: 9 }]],
233
+ ['body #1', [], []],
234
+ ['body #a1', ['#a1'], [{ byteStart: 5, byteEnd: 8 }]],
235
+ ['#', [], []],
236
+ ['text #', [], []],
237
+ ['text # text', [], []],
238
+ [
239
+ 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
240
+ ['#thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
241
+ [{ byteStart: 5, byteEnd: 71 }],
242
+ ],
243
+ [
244
+ 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
245
+ [],
246
+ [],
247
+ ],
248
+ [
249
+ 'its a #double#rainbow',
250
+ ['#double#rainbow'],
251
+ [{ byteStart: 6, byteEnd: 21 }],
252
+ ],
253
+ ['##hashash', ['##hashash'], [{ byteStart: 0, byteEnd: 9 }]],
254
+ ['some #n0n3s@n5e!', ['#n0n3s@n5e'], [{ byteStart: 5, byteEnd: 15 }]],
255
+ [
256
+ 'works #with,punctuation',
257
+ ['#with,punctuation'],
258
+ [{ byteStart: 6, byteEnd: 23 }],
259
+ ],
260
+ [
261
+ 'strips trailing #punctuation, #like. #this!',
262
+ ['#punctuation', '#like', '#this'],
263
+ [
264
+ { byteStart: 16, byteEnd: 28 },
265
+ { byteStart: 30, byteEnd: 35 },
266
+ { byteStart: 37, byteEnd: 42 },
267
+ ],
268
+ ],
269
+ [
270
+ 'strips #multi_trailing___...',
271
+ ['#multi_trailing'],
272
+ [{ byteStart: 7, byteEnd: 22 }],
273
+ ],
274
+ [
275
+ 'works with #🦋 emoji, and #butter🦋fly',
276
+ ['#🦋', '#butter🦋fly'],
277
+ [
278
+ { byteStart: 11, byteEnd: 16 },
279
+ { byteStart: 28, byteEnd: 42 },
280
+ ],
281
+ ],
282
+ [
283
+ '#same #same #but #diff',
284
+ ['#same', '#same', '#but', '#diff'],
285
+ [
286
+ { byteStart: 0, byteEnd: 5 },
287
+ { byteStart: 6, byteEnd: 11 },
288
+ { byteStart: 12, byteEnd: 16 },
289
+ { byteStart: 17, byteEnd: 22 },
290
+ ],
291
+ ],
292
+ ]
293
+
294
+ for (const [input, tags, indices] of inputs) {
295
+ const rt = new RichText({ text: input })
296
+ await rt.detectFacets(agent)
297
+
298
+ let detectedTags: string[] = []
299
+ let detectedIndices: { byteStart: number; byteEnd: number }[] = []
300
+
301
+ for (const { facet } of rt.segments()) {
302
+ if (!facet) continue
303
+ for (const feature of facet.features) {
304
+ if (isTag(feature)) {
305
+ detectedTags.push(feature.tag)
306
+ }
307
+ }
308
+ detectedIndices.push(facet.index)
309
+ }
310
+
311
+ expect(detectedTags).toEqual(tags)
312
+ expect(detectedIndices).toEqual(indices)
313
+ }
314
+ })
211
315
  })
212
316
 
213
317
  function segmentToOutput(segment: RichTextSegment): string[] {