@atproto/api 0.20.22 → 0.20.25

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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/client/lexicons.d.ts +28 -12
  3. package/dist/client/lexicons.d.ts.map +1 -1
  4. package/dist/client/lexicons.js +14 -6
  5. package/dist/client/lexicons.js.map +1 -1
  6. package/dist/client/types/app/bsky/ageassurance/defs.d.ts +2 -0
  7. package/dist/client/types/app/bsky/ageassurance/defs.d.ts.map +1 -1
  8. package/dist/client/types/app/bsky/ageassurance/defs.js.map +1 -1
  9. package/package.json +22 -16
  10. package/definitions/labels.json +0 -170
  11. package/docs/moderation.md +0 -260
  12. package/jest.config.cjs +0 -23
  13. package/jest.d.ts +0 -20
  14. package/jest.setup.ts +0 -97
  15. package/scripts/code/labels.mjs +0 -77
  16. package/scripts/generate-code.mjs +0 -4
  17. package/src/age-assurance.test.ts +0 -218
  18. package/src/age-assurance.ts +0 -137
  19. package/src/agent.ts +0 -1645
  20. package/src/atp-agent.ts +0 -570
  21. package/src/bsky-agent.ts +0 -14
  22. package/src/const.ts +0 -1
  23. package/src/index.ts +0 -44
  24. package/src/mocker.ts +0 -220
  25. package/src/moderation/decision.ts +0 -389
  26. package/src/moderation/index.ts +0 -69
  27. package/src/moderation/mutewords.ts +0 -170
  28. package/src/moderation/subjects/account.ts +0 -44
  29. package/src/moderation/subjects/feed-generator.ts +0 -24
  30. package/src/moderation/subjects/notification.ts +0 -25
  31. package/src/moderation/subjects/post.ts +0 -433
  32. package/src/moderation/subjects/profile.ts +0 -26
  33. package/src/moderation/subjects/status.ts +0 -27
  34. package/src/moderation/subjects/user-list.ts +0 -48
  35. package/src/moderation/types.ts +0 -191
  36. package/src/moderation/ui.ts +0 -21
  37. package/src/moderation/util.ts +0 -111
  38. package/src/predicate.ts +0 -52
  39. package/src/rich-text/detection.ts +0 -146
  40. package/src/rich-text/rich-text.ts +0 -418
  41. package/src/rich-text/sanitization.ts +0 -42
  42. package/src/rich-text/unicode.ts +0 -47
  43. package/src/rich-text/util.ts +0 -15
  44. package/src/session-manager.ts +0 -5
  45. package/src/types.ts +0 -165
  46. package/src/util.ts +0 -96
  47. package/tests/atp-agent.test.ts +0 -3863
  48. package/tests/dispatcher.test.ts +0 -527
  49. package/tests/errors.test.ts +0 -29
  50. package/tests/moderation-behaviors.test.ts +0 -951
  51. package/tests/moderation-custom-labels.test.ts +0 -334
  52. package/tests/moderation-mutewords.test.ts +0 -1299
  53. package/tests/moderation-prefs.test.ts +0 -402
  54. package/tests/moderation-quoteposts.test.ts +0 -277
  55. package/tests/moderation.test.ts +0 -739
  56. package/tests/rich-text-detection.test.ts +0 -456
  57. package/tests/rich-text-sanitization.test.ts +0 -216
  58. package/tests/rich-text.test.ts +0 -705
  59. package/tests/util/echo-server.ts +0 -37
  60. package/tests/util/moderation-behavior.ts +0 -268
  61. package/tsconfig.build.json +0 -9
  62. package/tsconfig.build.tsbuildinfo +0 -1
  63. package/tsconfig.json +0 -7
  64. package/tsconfig.tests.json +0 -10
@@ -1,26 +0,0 @@
1
- import { ModerationDecision } from '../decision.js'
2
- import { Label, ModerationOpts, ModerationSubjectProfile } from '../types.js'
3
-
4
- export function decideProfile(
5
- subject: ModerationSubjectProfile,
6
- opts: ModerationOpts,
7
- ): ModerationDecision {
8
- const acc = new ModerationDecision()
9
-
10
- acc.setDid(subject.did)
11
- acc.setIsMe(subject.did === opts.userDid)
12
- for (const label of filterProfileLabels(subject.labels)) {
13
- acc.addLabel('profile', label, opts)
14
- }
15
-
16
- return acc
17
- }
18
-
19
- export function filterProfileLabels(labels?: Label[]): Label[] {
20
- if (!labels) {
21
- return []
22
- }
23
- return labels.filter((label) =>
24
- label.uri.endsWith('/app.bsky.actor.profile/self'),
25
- )
26
- }
@@ -1,27 +0,0 @@
1
- import { ModerationDecision } from '../decision.js'
2
- import { ModerationOpts, ModerationSubjectProfile } from '../types.js'
3
- import { decideAccount } from './account.js'
4
- import { decideProfile } from './profile.js'
5
-
6
- export function decideStatus(
7
- subject: ModerationSubjectProfile,
8
- opts: ModerationOpts,
9
- ): ModerationDecision {
10
- const acc = new ModerationDecision()
11
-
12
- acc.setDid(subject.did)
13
- acc.setIsMe(subject.did === opts.userDid)
14
- if ('status' in subject) {
15
- if (subject.status?.labels?.length) {
16
- for (const label of subject.status.labels) {
17
- acc.addLabel('content', label, opts)
18
- }
19
- }
20
- }
21
-
22
- return ModerationDecision.merge(
23
- acc,
24
- decideAccount(subject, opts),
25
- decideProfile(subject, opts),
26
- )
27
- }
@@ -1,48 +0,0 @@
1
- import { AtUri } from '@atproto/syntax'
2
- import { AppBskyActorDefs } from '../../client/index.js'
3
- import { ModerationDecision } from '../decision.js'
4
- import { ModerationOpts, ModerationSubjectUserList } from '../types.js'
5
- import { decideAccount } from './account.js'
6
- import { decideProfile } from './profile.js'
7
-
8
- export function decideUserList(
9
- subject: ModerationSubjectUserList,
10
- opts: ModerationOpts,
11
- ): ModerationDecision {
12
- const acc = new ModerationDecision()
13
-
14
- const creator =
15
- // Note: ListViewBasic should not contain a creator field, but let's support it anyway
16
- 'creator' in subject && isProfile(subject.creator)
17
- ? subject.creator
18
- : undefined
19
-
20
- if (creator) {
21
- acc.setDid(creator.did)
22
- acc.setIsMe(creator.did === opts.userDid)
23
- if (subject.labels?.length) {
24
- for (const label of subject.labels) {
25
- acc.addLabel('content', label, opts)
26
- }
27
- }
28
- return ModerationDecision.merge(
29
- acc,
30
- decideAccount(creator, opts),
31
- decideProfile(creator, opts),
32
- )
33
- }
34
-
35
- const creatorDid = new AtUri(subject.uri).hostname
36
- acc.setDid(creatorDid)
37
- acc.setIsMe(creatorDid === opts.userDid)
38
- if (subject.labels?.length) {
39
- for (const label of subject.labels) {
40
- acc.addLabel('content', label, opts)
41
- }
42
- }
43
- return acc
44
- }
45
-
46
- function isProfile(v: any): v is AppBskyActorDefs.ProfileViewBasic {
47
- return v && typeof v === 'object' && 'did' in v
48
- }
@@ -1,191 +0,0 @@
1
- import {
2
- AppBskyActorDefs,
3
- AppBskyFeedDefs,
4
- AppBskyGraphDefs,
5
- AppBskyNotificationListNotifications,
6
- ChatBskyActorDefs,
7
- ComAtprotoLabelDefs,
8
- } from '../client/index.js'
9
- import { KnownLabelValue } from './const/labels.js'
10
- import { MuteWordMatch } from './mutewords.js'
11
-
12
- // syntax
13
- // =
14
-
15
- export const CUSTOM_LABEL_VALUE_RE = /^[a-z-]+$/
16
-
17
- // behaviors
18
- // =
19
-
20
- export interface ModerationBehavior {
21
- profileList?: 'blur' | 'alert' | 'inform'
22
- profileView?: 'blur' | 'alert' | 'inform'
23
- avatar?: 'blur' | 'alert'
24
- banner?: 'blur'
25
- displayName?: 'blur'
26
- contentList?: 'blur' | 'alert' | 'inform'
27
- contentView?: 'blur' | 'alert' | 'inform'
28
- contentMedia?: 'blur'
29
- }
30
- export const BLOCK_BEHAVIOR: ModerationBehavior = {
31
- profileList: 'blur',
32
- profileView: 'alert',
33
- avatar: 'blur',
34
- banner: 'blur',
35
- contentList: 'blur',
36
- contentView: 'blur',
37
- }
38
- export const MUTE_BEHAVIOR: ModerationBehavior = {
39
- profileList: 'inform',
40
- profileView: 'alert',
41
- contentList: 'blur',
42
- contentView: 'inform',
43
- }
44
- export const MUTEWORD_BEHAVIOR: ModerationBehavior = {
45
- contentList: 'blur',
46
- contentView: 'blur',
47
- }
48
- export const HIDE_BEHAVIOR: ModerationBehavior = {
49
- contentList: 'blur',
50
- contentView: 'blur',
51
- }
52
- export const NOOP_BEHAVIOR: ModerationBehavior = {}
53
-
54
- // labels
55
- // =
56
-
57
- export type Label = ComAtprotoLabelDefs.Label
58
- export type LabelTarget = 'account' | 'profile' | 'content'
59
- export type LabelPreference = 'ignore' | 'warn' | 'hide'
60
-
61
- export type LabelValueDefinitionFlag =
62
- | 'no-override'
63
- | 'adult'
64
- | 'unauthed'
65
- | 'no-self'
66
-
67
- export interface InterpretedLabelValueDefinition
68
- extends ComAtprotoLabelDefs.LabelValueDefinition {
69
- definedBy?: string | undefined // did of labeler or undefined for global
70
- configurable: boolean
71
- defaultSetting: LabelPreference // type narrowing
72
- flags: LabelValueDefinitionFlag[]
73
- behaviors: {
74
- account?: ModerationBehavior
75
- profile?: ModerationBehavior
76
- content?: ModerationBehavior
77
- }
78
- }
79
-
80
- export type LabelDefinitionMap = Record<
81
- KnownLabelValue,
82
- InterpretedLabelValueDefinition
83
- >
84
-
85
- // subjects
86
- // =
87
-
88
- export type ModerationSubjectProfile =
89
- | AppBskyActorDefs.ProfileViewBasic
90
- | AppBskyActorDefs.ProfileView
91
- | AppBskyActorDefs.ProfileViewDetailed
92
- | ChatBskyActorDefs.ProfileViewBasic
93
-
94
- export type ModerationSubjectPost = AppBskyFeedDefs.PostView
95
-
96
- export type ModerationSubjectNotification =
97
- AppBskyNotificationListNotifications.Notification
98
-
99
- export type ModerationSubjectFeedGenerator = AppBskyFeedDefs.GeneratorView
100
-
101
- export type ModerationSubjectUserList =
102
- | AppBskyGraphDefs.ListViewBasic
103
- | AppBskyGraphDefs.ListView
104
-
105
- export type ModerationSubject =
106
- | ModerationSubjectProfile
107
- | ModerationSubjectPost
108
- | ModerationSubjectNotification
109
- | ModerationSubjectFeedGenerator
110
- | ModerationSubjectUserList
111
-
112
- // behaviors
113
- // =
114
-
115
- export type ModerationCauseSource =
116
- | { type: 'user' }
117
- | { type: 'list'; list: AppBskyGraphDefs.ListViewBasic }
118
- | { type: 'labeler'; did: string }
119
-
120
- export type ModerationCause =
121
- | {
122
- type: 'blocking'
123
- source: ModerationCauseSource
124
- priority: 3
125
- downgraded?: boolean
126
- }
127
- | {
128
- type: 'blocked-by'
129
- source: ModerationCauseSource
130
- priority: 4
131
- downgraded?: boolean
132
- }
133
- | {
134
- type: 'block-other'
135
- source: ModerationCauseSource
136
- priority: 4
137
- downgraded?: boolean
138
- }
139
- | {
140
- type: 'label'
141
- source: ModerationCauseSource
142
- label: Label
143
- labelDef: InterpretedLabelValueDefinition
144
- target: LabelTarget
145
- setting: LabelPreference
146
- behavior: ModerationBehavior
147
- noOverride: boolean
148
- priority: 1 | 2 | 5 | 7 | 8
149
- downgraded?: boolean
150
- }
151
- | {
152
- type: 'muted'
153
- source: ModerationCauseSource
154
- priority: 6
155
- downgraded?: boolean
156
- }
157
- | {
158
- type: 'mute-word'
159
- source: ModerationCauseSource
160
- priority: 6
161
- downgraded?: boolean
162
- matches: MuteWordMatch[]
163
- }
164
- | {
165
- type: 'hidden'
166
- source: ModerationCauseSource
167
- priority: 6
168
- downgraded?: boolean
169
- }
170
-
171
- export interface ModerationPrefsLabeler {
172
- did: string
173
- labels: Record<string, LabelPreference>
174
- }
175
-
176
- export interface ModerationPrefs {
177
- adultContentEnabled: boolean
178
- labels: Record<string, LabelPreference>
179
- labelers: ModerationPrefsLabeler[]
180
- mutedWords: AppBskyActorDefs.MutedWord[]
181
- hiddenPosts: string[]
182
- }
183
-
184
- export interface ModerationOpts {
185
- userDid: string | undefined
186
- prefs: ModerationPrefs
187
- /**
188
- * Map of labeler did -> custom definitions
189
- */
190
- labelDefs?: Record<string, InterpretedLabelValueDefinition[]>
191
- }
@@ -1,21 +0,0 @@
1
- import { ModerationCause } from './types.js'
2
-
3
- export class ModerationUI {
4
- noOverride = false
5
- filters: ModerationCause[] = []
6
- blurs: ModerationCause[] = []
7
- alerts: ModerationCause[] = []
8
- informs: ModerationCause[] = []
9
- get filter(): boolean {
10
- return this.filters.length !== 0
11
- }
12
- get blur(): boolean {
13
- return this.blurs.length !== 0
14
- }
15
- get alert(): boolean {
16
- return this.alerts.length !== 0
17
- }
18
- get inform(): boolean {
19
- return this.informs.length !== 0
20
- }
21
- }
@@ -1,111 +0,0 @@
1
- import {
2
- AppBskyEmbedRecord,
3
- AppBskyEmbedRecordWithMedia,
4
- AppBskyLabelerDefs,
5
- ComAtprotoLabelDefs,
6
- } from '../client/index.js'
7
- import { asPredicate } from '../client/util.js'
8
- import {
9
- InterpretedLabelValueDefinition,
10
- LabelPreference,
11
- LabelValueDefinitionFlag,
12
- ModerationBehavior,
13
- } from './types.js'
14
-
15
- export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View {
16
- return Boolean(embed && AppBskyEmbedRecord.isView(embed))
17
- }
18
-
19
- export function isQuotedPostWithMedia(
20
- embed: unknown,
21
- ): embed is AppBskyEmbedRecordWithMedia.View {
22
- return Boolean(embed && AppBskyEmbedRecordWithMedia.isView(embed))
23
- }
24
-
25
- export function interpretLabelValueDefinition(
26
- def: ComAtprotoLabelDefs.LabelValueDefinition,
27
- definedBy: string | undefined,
28
- ): InterpretedLabelValueDefinition {
29
- const behaviors: {
30
- account: ModerationBehavior
31
- profile: ModerationBehavior
32
- content: ModerationBehavior
33
- } = {
34
- account: {},
35
- profile: {},
36
- content: {},
37
- }
38
- const alertOrInform: 'alert' | 'inform' | undefined =
39
- def.severity === 'alert'
40
- ? 'alert'
41
- : def.severity === 'inform'
42
- ? 'inform'
43
- : undefined
44
- if (def.blurs === 'content') {
45
- // target=account, blurs=content
46
- behaviors.account.profileList = alertOrInform
47
- behaviors.account.profileView = alertOrInform
48
- behaviors.account.contentList = 'blur'
49
- behaviors.account.contentView = def.adultOnly ? 'blur' : alertOrInform
50
- // target=profile, blurs=content
51
- behaviors.profile.profileList = alertOrInform
52
- behaviors.profile.profileView = alertOrInform
53
- // target=content, blurs=content
54
- behaviors.content.contentList = 'blur'
55
- behaviors.content.contentView = def.adultOnly ? 'blur' : alertOrInform
56
- } else if (def.blurs === 'media') {
57
- // target=account, blurs=media
58
- behaviors.account.profileList = alertOrInform
59
- behaviors.account.profileView = alertOrInform
60
- behaviors.account.avatar = 'blur'
61
- behaviors.account.banner = 'blur'
62
- // target=profile, blurs=media
63
- behaviors.profile.profileList = alertOrInform
64
- behaviors.profile.profileView = alertOrInform
65
- behaviors.profile.avatar = 'blur'
66
- behaviors.profile.banner = 'blur'
67
- // target=content, blurs=media
68
- behaviors.content.contentMedia = 'blur'
69
- } else if (def.blurs === 'none') {
70
- // target=account, blurs=none
71
- behaviors.account.profileList = alertOrInform
72
- behaviors.account.profileView = alertOrInform
73
- behaviors.account.contentList = alertOrInform
74
- behaviors.account.contentView = alertOrInform
75
- // target=profile, blurs=none
76
- behaviors.profile.profileList = alertOrInform
77
- behaviors.profile.profileView = alertOrInform
78
- // target=content, blurs=none
79
- behaviors.content.contentList = alertOrInform
80
- behaviors.content.contentView = alertOrInform
81
- }
82
-
83
- let defaultSetting: LabelPreference = 'warn'
84
- if (def.defaultSetting === 'hide' || def.defaultSetting === 'ignore') {
85
- defaultSetting = def.defaultSetting as LabelPreference
86
- }
87
-
88
- const flags: LabelValueDefinitionFlag[] = ['no-self']
89
- if (def.adultOnly) {
90
- flags.push('adult')
91
- }
92
-
93
- return {
94
- ...def,
95
- definedBy,
96
- configurable: true,
97
- defaultSetting,
98
- flags,
99
- behaviors,
100
- }
101
- }
102
-
103
- export function interpretLabelValueDefinitions(
104
- labelerView: AppBskyLabelerDefs.LabelerViewDetailed,
105
- ): InterpretedLabelValueDefinition[] {
106
- return (labelerView.policies?.labelValueDefinitions || [])
107
- .filter(asPredicate(ComAtprotoLabelDefs.validateLabelValueDefinition))
108
- .map((labelValDef) =>
109
- interpretLabelValueDefinition(labelValDef, labelerView.creator.did),
110
- )
111
- }
package/src/predicate.ts DELETED
@@ -1,52 +0,0 @@
1
- import { AppBskyActorDefs, AppBskyActorProfile } from './client/index.js'
2
- import { asPredicate } from './client/util.js'
3
-
4
- export const isValidProfile = asPredicate(AppBskyActorProfile.validateRecord)
5
- export const isValidAdultContentPref = asPredicate(
6
- AppBskyActorDefs.validateAdultContentPref,
7
- )
8
- export const isValidBskyAppStatePref = asPredicate(
9
- AppBskyActorDefs.validateBskyAppStatePref,
10
- )
11
- export const isValidContentLabelPref = asPredicate(
12
- AppBskyActorDefs.validateContentLabelPref,
13
- )
14
- export const isValidFeedViewPref = asPredicate(
15
- AppBskyActorDefs.validateFeedViewPref,
16
- )
17
- export const isValidHiddenPostsPref = asPredicate(
18
- AppBskyActorDefs.validateHiddenPostsPref,
19
- )
20
- export const isValidInterestsPref = asPredicate(
21
- AppBskyActorDefs.validateInterestsPref,
22
- )
23
- export const isValidLabelersPref = asPredicate(
24
- AppBskyActorDefs.validateLabelersPref,
25
- )
26
- export const isValidMutedWordsPref = asPredicate(
27
- AppBskyActorDefs.validateMutedWordsPref,
28
- )
29
- export const isValidPersonalDetailsPref = asPredicate(
30
- AppBskyActorDefs.validatePersonalDetailsPref,
31
- )
32
- export const isValidDeclaredAgePref = asPredicate(
33
- AppBskyActorDefs.validateDeclaredAgePref,
34
- )
35
- export const isValidPostInteractionSettingsPref = asPredicate(
36
- AppBskyActorDefs.validatePostInteractionSettingsPref,
37
- )
38
- export const isValidSavedFeedsPref = asPredicate(
39
- AppBskyActorDefs.validateSavedFeedsPref,
40
- )
41
- export const isValidSavedFeedsPrefV2 = asPredicate(
42
- AppBskyActorDefs.validateSavedFeedsPrefV2,
43
- )
44
- export const isValidThreadViewPref = asPredicate(
45
- AppBskyActorDefs.validateThreadViewPref,
46
- )
47
- export const isValidVerificationPrefs = asPredicate(
48
- AppBskyActorDefs.validateVerificationPrefs,
49
- )
50
- export const isValidLiveEventPreferences = asPredicate(
51
- AppBskyActorDefs.validateLiveEventPreferences,
52
- )
@@ -1,146 +0,0 @@
1
- import TLDs from 'tlds' with { type: 'json' }
2
- import { AppBskyRichtextFacet } from '../client/index.js'
3
- import { UnicodeString } from './unicode.js'
4
- import {
5
- CASHTAG_REGEX,
6
- MENTION_REGEX,
7
- TAG_REGEX,
8
- TRAILING_PUNCTUATION_REGEX,
9
- URL_REGEX,
10
- } from './util.js'
11
-
12
- export type Facet = AppBskyRichtextFacet.Main
13
-
14
- export function detectFacets(text: UnicodeString): Facet[] | undefined {
15
- let match
16
- const facets: Facet[] = []
17
- {
18
- // mentions
19
- const re = MENTION_REGEX
20
- while ((match = re.exec(text.utf16))) {
21
- if (!isValidDomain(match[3]) && !match[3].endsWith('.test')) {
22
- continue // probably not a handle
23
- }
24
-
25
- const start = text.utf16.indexOf(match[3], match.index) - 1
26
- facets.push({
27
- $type: 'app.bsky.richtext.facet',
28
- index: {
29
- byteStart: text.utf16IndexToUtf8Index(start),
30
- byteEnd: text.utf16IndexToUtf8Index(start + match[3].length + 1),
31
- },
32
- features: [
33
- {
34
- $type: 'app.bsky.richtext.facet#mention',
35
- did: match[3], // must be resolved afterwards
36
- },
37
- ],
38
- })
39
- }
40
- }
41
- {
42
- // links
43
- const re = URL_REGEX
44
- while ((match = re.exec(text.utf16))) {
45
- let uri = match[2]
46
- if (!uri.startsWith('http')) {
47
- const domain = match.groups?.domain
48
- if (!domain || !isValidDomain(domain)) {
49
- continue
50
- }
51
- uri = `https://${uri}`
52
- }
53
- const start = text.utf16.indexOf(match[2], match.index)
54
- const index = { start, end: start + match[2].length }
55
- // strip ending puncuation
56
- if (/[.,;:!?]$/.test(uri)) {
57
- uri = uri.slice(0, -1)
58
- index.end--
59
- }
60
- if (/[)]$/.test(uri) && !uri.includes('(')) {
61
- uri = uri.slice(0, -1)
62
- index.end--
63
- }
64
- facets.push({
65
- index: {
66
- byteStart: text.utf16IndexToUtf8Index(index.start),
67
- byteEnd: text.utf16IndexToUtf8Index(index.end),
68
- },
69
- features: [
70
- {
71
- $type: 'app.bsky.richtext.facet#link',
72
- uri,
73
- },
74
- ],
75
- })
76
- }
77
- }
78
- {
79
- const re = TAG_REGEX
80
- while ((match = re.exec(text.utf16))) {
81
- const leading = match[1]
82
- let tag = match[2]
83
-
84
- if (!tag) continue
85
-
86
- // strip ending punctuation and any spaces
87
- tag = tag.trim().replace(TRAILING_PUNCTUATION_REGEX, '')
88
-
89
- if (tag.length === 0 || tag.length > 64) continue
90
-
91
- const index = match.index + leading.length
92
-
93
- facets.push({
94
- index: {
95
- byteStart: text.utf16IndexToUtf8Index(index),
96
- byteEnd: text.utf16IndexToUtf8Index(index + 1 + tag.length),
97
- },
98
- features: [
99
- {
100
- $type: 'app.bsky.richtext.facet#tag',
101
- tag: tag,
102
- },
103
- ],
104
- })
105
- }
106
- }
107
- {
108
- // cashtags
109
- const re = CASHTAG_REGEX
110
- while ((match = re.exec(text.utf16))) {
111
- const leading = match[1]
112
- let ticker = match[2]
113
-
114
- if (!ticker) continue
115
-
116
- // Normalize to uppercase
117
- ticker = ticker.toUpperCase()
118
-
119
- const index = match.index + leading.length
120
-
121
- facets.push({
122
- index: {
123
- byteStart: text.utf16IndexToUtf8Index(index),
124
- byteEnd: text.utf16IndexToUtf8Index(index + 1 + ticker.length), // +1 for $
125
- },
126
- features: [
127
- {
128
- $type: 'app.bsky.richtext.facet#tag',
129
- tag: '$' + ticker, // Store with $ prefix
130
- },
131
- ],
132
- })
133
- }
134
- }
135
- return facets.length > 0 ? facets : undefined
136
- }
137
-
138
- function isValidDomain(str: string): boolean {
139
- return !!TLDs.find((tld) => {
140
- const i = str.lastIndexOf(tld)
141
- if (i === -1) {
142
- return false
143
- }
144
- return str.charAt(i - 1) === '.' && i === str.length - tld.length
145
- })
146
- }