diff --git a/.changeset/three-snakes-turn.md b/.changeset/three-snakes-turn.md new file mode 100644 index 00000000000..cd086115761 --- /dev/null +++ b/.changeset/three-snakes-turn.md @@ -0,0 +1,7 @@ +--- +'@atproto/bsky': patch +'@atproto/api': patch +'@atproto/pds': patch +--- + +Introduce general support for tags on posts diff --git a/lexicons/app/bsky/feed/post.json b/lexicons/app/bsky/feed/post.json index 5622b5cfd50..b21f01b6050 100644 --- a/lexicons/app/bsky/feed/post.json +++ b/lexicons/app/bsky/feed/post.json @@ -38,6 +38,12 @@ "type": "union", "refs": ["com.atproto.label.defs#selfLabels"] }, + "tags": { + "type": "array", + "maxLength": 8, + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }, + "description": "Additional non-inline tags describing this post." + }, "createdAt": { "type": "string", "format": "datetime" } } } diff --git a/lexicons/app/bsky/richtext/facet.json b/lexicons/app/bsky/richtext/facet.json index 9addf2f34b7..ea8f2cba288 100644 --- a/lexicons/app/bsky/richtext/facet.json +++ b/lexicons/app/bsky/richtext/facet.json @@ -9,7 +9,7 @@ "index": { "type": "ref", "ref": "#byteSlice" }, "features": { "type": "array", - "items": { "type": "union", "refs": ["#mention", "#link"] } + "items": { "type": "union", "refs": ["#mention", "#link", "#tag"] } } } }, @@ -29,6 +29,14 @@ "uri": { "type": "string", "format": "uri" } } }, + "tag": { + "type": "object", + "description": "A hashtag.", + "required": ["tag"], + "properties": { + "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + } + }, "byteSlice": { "type": "object", "description": "A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings.", diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 6b9b7b7f14f..a5cbf08d608 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5622,6 +5622,16 @@ export const schemaDict = { type: 'union', refs: ['lex:com.atproto.label.defs#selfLabels'], }, + tags: { + type: 'array', + maxLength: 8, + items: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + description: 'Additional non-inline tags describing this post.', + }, createdAt: { type: 'string', format: 'datetime', @@ -6761,6 +6771,7 @@ export const schemaDict = { refs: [ 'lex:app.bsky.richtext.facet#mention', 'lex:app.bsky.richtext.facet#link', + 'lex:app.bsky.richtext.facet#tag', ], }, }, @@ -6788,6 +6799,18 @@ export const schemaDict = { }, }, }, + tag: { + type: 'object', + description: 'A hashtag.', + required: ['tag'], + properties: { + tag: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + }, + }, byteSlice: { type: 'object', description: diff --git a/packages/api/src/client/types/app/bsky/feed/post.ts b/packages/api/src/client/types/app/bsky/feed/post.ts index 1e326692640..a3299e19035 100644 --- a/packages/api/src/client/types/app/bsky/feed/post.ts +++ b/packages/api/src/client/types/app/bsky/feed/post.ts @@ -29,6 +29,8 @@ export interface Record { labels?: | ComAtprotoLabelDefs.SelfLabels | { $type: string; [k: string]: unknown } + /** Additional non-inline tags describing this post. */ + tags?: string[] createdAt: string [k: string]: unknown } diff --git a/packages/api/src/client/types/app/bsky/richtext/facet.ts b/packages/api/src/client/types/app/bsky/richtext/facet.ts index cea86685a0f..96573bb06fe 100644 --- a/packages/api/src/client/types/app/bsky/richtext/facet.ts +++ b/packages/api/src/client/types/app/bsky/richtext/facet.ts @@ -8,7 +8,7 @@ import { CID } from 'multiformats/cid' export interface Main { index: ByteSlice - features: (Mention | Link | { $type: string; [k: string]: unknown })[] + features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[] [k: string]: unknown } @@ -61,6 +61,22 @@ export function validateLink(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#link', v) } +/** A hashtag. */ +export interface Tag { + tag: string + [k: string]: unknown +} + +export function isTag(v: unknown): v is Tag { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'app.bsky.richtext.facet#tag' + ) +} + +export function validateTag(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.richtext.facet#tag', v) +} + /** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */ export interface ByteSlice { byteStart: number diff --git a/packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts b/packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts new file mode 100644 index 00000000000..9d4e5bd4cfb --- /dev/null +++ b/packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts @@ -0,0 +1,9 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema.alterTable('post').addColumn('tags', 'jsonb').execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('post').dropColumn('tags').execute() +} diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/db/migrations/index.ts index bf18d8dd15b..9e8bfe9cf7f 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/db/migrations/index.ts @@ -28,3 +28,4 @@ export * as _20230817T195936007Z from './20230817T195936007Z-native-notification export * as _20230830T205507322Z from './20230830T205507322Z-suggested-feeds' export * as _20230904T211011773Z from './20230904T211011773Z-block-lists' export * as _20230906T222220386Z from './20230906T222220386Z-thread-gating' +export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post' diff --git a/packages/bsky/src/db/tables/post.ts b/packages/bsky/src/db/tables/post.ts index c627efa39e7..6c01b76c8e0 100644 --- a/packages/bsky/src/db/tables/post.ts +++ b/packages/bsky/src/db/tables/post.ts @@ -12,6 +12,7 @@ export interface Post { replyParent: string | null replyParentCid: string | null langs: string[] | null + tags: string[] | null invalidReplyRoot: boolean | null violatesThreadGate: boolean | null createdAt: string diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 6b9b7b7f14f..a5cbf08d608 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5622,6 +5622,16 @@ export const schemaDict = { type: 'union', refs: ['lex:com.atproto.label.defs#selfLabels'], }, + tags: { + type: 'array', + maxLength: 8, + items: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + description: 'Additional non-inline tags describing this post.', + }, createdAt: { type: 'string', format: 'datetime', @@ -6761,6 +6771,7 @@ export const schemaDict = { refs: [ 'lex:app.bsky.richtext.facet#mention', 'lex:app.bsky.richtext.facet#link', + 'lex:app.bsky.richtext.facet#tag', ], }, }, @@ -6788,6 +6799,18 @@ export const schemaDict = { }, }, }, + tag: { + type: 'object', + description: 'A hashtag.', + required: ['tag'], + properties: { + tag: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + }, + }, byteSlice: { type: 'object', description: diff --git a/packages/bsky/src/lexicon/types/app/bsky/feed/post.ts b/packages/bsky/src/lexicon/types/app/bsky/feed/post.ts index 8942bc724cd..93870b4452d 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/feed/post.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/feed/post.ts @@ -29,6 +29,8 @@ export interface Record { labels?: | ComAtprotoLabelDefs.SelfLabels | { $type: string; [k: string]: unknown } + /** Additional non-inline tags describing this post. */ + tags?: string[] createdAt: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/app/bsky/richtext/facet.ts b/packages/bsky/src/lexicon/types/app/bsky/richtext/facet.ts index a7369ee8d57..2c5b2d723a9 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/richtext/facet.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/richtext/facet.ts @@ -8,7 +8,7 @@ import { CID } from 'multiformats/cid' export interface Main { index: ByteSlice - features: (Mention | Link | { $type: string; [k: string]: unknown })[] + features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[] [k: string]: unknown } @@ -61,6 +61,22 @@ export function validateLink(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#link', v) } +/** A hashtag. */ +export interface Tag { + tag: string + [k: string]: unknown +} + +export function isTag(v: unknown): v is Tag { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'app.bsky.richtext.facet#tag' + ) +} + +export function validateTag(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.richtext.facet#tag', v) +} + /** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */ export interface ByteSlice { byteStart: number diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index f955979e81e..dab9673d9db 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -147,6 +147,7 @@ export class FeedService { 'post_agg.likeCount as likeCount', 'post_agg.repostCount as repostCount', 'post_agg.replyCount as replyCount', + 'post.tags as tags', db .selectFrom('repost') .if(!viewer, (q) => q.where(noMatch)) diff --git a/packages/bsky/src/services/indexing/plugins/post.ts b/packages/bsky/src/services/indexing/plugins/post.ts index f57bc10179b..40835348f01 100644 --- a/packages/bsky/src/services/indexing/plugins/post.ts +++ b/packages/bsky/src/services/indexing/plugins/post.ts @@ -76,6 +76,9 @@ const insertFn = async ( langs: obj.langs?.length ? sql`${JSON.stringify(obj.langs)}` // sidesteps kysely's array serialization, which is non-jsonb : null, + tags: obj.tags?.length + ? sql`${JSON.stringify(obj.tags)}` // sidesteps kysely's array serialization, which is non-jsonb + : null, indexedAt: timestamp, } const [insertedPost] = await Promise.all([ diff --git a/packages/bsky/tests/views/posts.test.ts b/packages/bsky/tests/views/posts.test.ts index 99ec565dc59..6fa12a085df 100644 --- a/packages/bsky/tests/views/posts.test.ts +++ b/packages/bsky/tests/views/posts.test.ts @@ -1,4 +1,4 @@ -import AtpAgent from '@atproto/api' +import AtpAgent, { AppBskyFeedPost } from '@atproto/api' import { TestNetwork } from '@atproto/dev-env' import { forSnapshot, stripViewerFromPost } from '../_util' import { SeedClient } from '../seeds/client' @@ -7,6 +7,7 @@ import basicSeed from '../seeds/basic' describe('pds posts views', () => { let network: TestNetwork let agent: AtpAgent + let pdsAgent: AtpAgent let sc: SeedClient beforeAll(async () => { @@ -14,7 +15,7 @@ describe('pds posts views', () => { dbPostgresSchema: 'bsky_views_posts', }) agent = network.bsky.getClient() - const pdsAgent = network.pds.getClient() + pdsAgent = network.pds.getClient() sc = new SeedClient(pdsAgent) await basicSeed(sc) await network.processAll() @@ -83,4 +84,27 @@ describe('pds posts views', () => { ].sort() expect(receivedUris).toEqual(expected) }) + + it('allows for creating posts with tags', async () => { + const post: AppBskyFeedPost.Record = { + text: 'hello world', + tags: ['javascript', 'hehe'], + createdAt: new Date().toISOString(), + } + + const { uri } = await pdsAgent.api.app.bsky.feed.post.create( + { repo: sc.dids.alice }, + post, + sc.getHeaders(sc.dids.alice), + ) + + await network.processAll() + await network.bsky.processAll() + + const { data } = await agent.api.app.bsky.feed.getPosts({ uris: [uri] }) + + expect(data.posts.length).toBe(1) + // @ts-ignore we know it's a post record + expect(data.posts[0].record.tags).toEqual(['javascript', 'hehe']) + }) }) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 6b9b7b7f14f..a5cbf08d608 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5622,6 +5622,16 @@ export const schemaDict = { type: 'union', refs: ['lex:com.atproto.label.defs#selfLabels'], }, + tags: { + type: 'array', + maxLength: 8, + items: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + description: 'Additional non-inline tags describing this post.', + }, createdAt: { type: 'string', format: 'datetime', @@ -6761,6 +6771,7 @@ export const schemaDict = { refs: [ 'lex:app.bsky.richtext.facet#mention', 'lex:app.bsky.richtext.facet#link', + 'lex:app.bsky.richtext.facet#tag', ], }, }, @@ -6788,6 +6799,18 @@ export const schemaDict = { }, }, }, + tag: { + type: 'object', + description: 'A hashtag.', + required: ['tag'], + properties: { + tag: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + }, + }, byteSlice: { type: 'object', description: diff --git a/packages/pds/src/lexicon/types/app/bsky/feed/post.ts b/packages/pds/src/lexicon/types/app/bsky/feed/post.ts index 8942bc724cd..93870b4452d 100644 --- a/packages/pds/src/lexicon/types/app/bsky/feed/post.ts +++ b/packages/pds/src/lexicon/types/app/bsky/feed/post.ts @@ -29,6 +29,8 @@ export interface Record { labels?: | ComAtprotoLabelDefs.SelfLabels | { $type: string; [k: string]: unknown } + /** Additional non-inline tags describing this post. */ + tags?: string[] createdAt: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/app/bsky/richtext/facet.ts b/packages/pds/src/lexicon/types/app/bsky/richtext/facet.ts index a7369ee8d57..2c5b2d723a9 100644 --- a/packages/pds/src/lexicon/types/app/bsky/richtext/facet.ts +++ b/packages/pds/src/lexicon/types/app/bsky/richtext/facet.ts @@ -8,7 +8,7 @@ import { CID } from 'multiformats/cid' export interface Main { index: ByteSlice - features: (Mention | Link | { $type: string; [k: string]: unknown })[] + features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[] [k: string]: unknown } @@ -61,6 +61,22 @@ export function validateLink(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#link', v) } +/** A hashtag. */ +export interface Tag { + tag: string + [k: string]: unknown +} + +export function isTag(v: unknown): v is Tag { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'app.bsky.richtext.facet#tag' + ) +} + +export function validateTag(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.richtext.facet#tag', v) +} + /** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */ export interface ByteSlice { byteStart: number diff --git a/packages/pds/src/repo/prepare.ts b/packages/pds/src/repo/prepare.ts index 581701f1f01..2147ef552b6 100644 --- a/packages/pds/src/repo/prepare.ts +++ b/packages/pds/src/repo/prepare.ts @@ -299,6 +299,8 @@ function assertNoExplicitSlurs(rkey: string, record: RepoRecord) { } else if (isFeedGenerator(record)) { toCheck += ' ' + rkey toCheck += ' ' + record.displayName + } else if (isPost(record)) { + toCheck += record.tags?.join(' ') } if (hasExplicitSlur(toCheck)) { throw new InvalidRecordError('Unacceptable slur in record') diff --git a/packages/pds/tests/create-post.test.ts b/packages/pds/tests/create-post.test.ts new file mode 100644 index 00000000000..e2763981fb0 --- /dev/null +++ b/packages/pds/tests/create-post.test.ts @@ -0,0 +1,45 @@ +import AtpAgent, { AppBskyFeedPost, AtUri } from '@atproto/api' +import { runTestServer, TestServerInfo } from './_util' +import { SeedClient } from './seeds/client' +import basicSeed from './seeds/basic' + +describe('pds posts record creation', () => { + let server: TestServerInfo + let agent: AtpAgent + let sc: SeedClient + + beforeAll(async () => { + server = await runTestServer({ + dbPostgresSchema: 'views_posts', + }) + agent = new AtpAgent({ service: server.url }) + sc = new SeedClient(agent) + await basicSeed(sc) + await server.processAll() + }) + + afterAll(async () => { + await server.close() + }) + + it('allows for creating posts with tags', async () => { + const post: AppBskyFeedPost.Record = { + text: 'hello world', + tags: ['javascript', 'hehe'], + createdAt: new Date().toISOString(), + } + + const res = await agent.api.app.bsky.feed.post.create( + { repo: sc.dids.alice }, + post, + sc.getHeaders(sc.dids.alice), + ) + const { value: record } = await agent.api.app.bsky.feed.post.get({ + repo: sc.dids.alice, + rkey: new AtUri(res.uri).rkey, + }) + + expect(record).toBeTruthy() + expect(record.tags).toEqual(['javascript', 'hehe']) + }) +})