diff --git a/backend/src/mastodon/timeline.ts b/backend/src/mastodon/timeline.ts index 6a44f9b..26f228f 100644 --- a/backend/src/mastodon/timeline.ts +++ b/backend/src/mastodon/timeline.ts @@ -112,8 +112,14 @@ export async function getPublicTimeline( domain: string, db: D1Database, localPreference: LocalPreference, - offset: number = 0 + offset: number = 0, + hashtag?: string ): Promise> { + let hashtagFilter = '' + if (hashtag) { + hashtagFilter = 'AND note_hashtags.value=?3' + } + const QUERY = ` SELECT objects.*, actors.id as actor_id, @@ -126,17 +132,24 @@ SELECT objects.*, FROM outbox_objects INNER JOIN objects ON objects.id=outbox_objects.object_id INNER JOIN actors ON actors.id=outbox_objects.actor_id +LEFT JOIN note_hashtags ON objects.id=note_hashtags.object_id WHERE objects.type='Note' AND ${localPreferenceQuery(localPreference)} AND json_extract(objects.properties, '$.inReplyTo') IS NULL AND outbox_objects.target = '${PUBLIC_GROUP}' + ${hashtagFilter} GROUP BY objects.id ORDER by outbox_objects.published_date DESC LIMIT ?1 OFFSET ?2 ` const DEFAULT_LIMIT = 20 - const { success, error, results } = await db.prepare(QUERY).bind(DEFAULT_LIMIT, offset).all() + let query = db.prepare(QUERY).bind(DEFAULT_LIMIT, offset) + if (hashtagFilter) { + query = db.prepare(QUERY).bind(DEFAULT_LIMIT, offset, hashtag) + } + + const { success, error, results } = await query.all() if (!success) { throw new Error('SQL error: ' + error) } diff --git a/backend/test/mastodon/timelines.spec.ts b/backend/test/mastodon/timelines.spec.ts index 8f7abe5..56729db 100644 --- a/backend/test/mastodon/timelines.spec.ts +++ b/backend/test/mastodon/timelines.spec.ts @@ -12,6 +12,7 @@ import * as timelines from 'wildebeest/backend/src/mastodon/timeline' import { insertLike } from 'wildebeest/backend/src/mastodon/like' import { insertReblog, createReblog } from 'wildebeest/backend/src/mastodon/reblog' import { createStatus } from 'wildebeest/backend/src/mastodon/status' +import { insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag' const userKEK = 'test_kek6' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -294,5 +295,46 @@ describe('Mastodon APIs', () => { assert.equal(data.length, 1) assert.equal(data[0].content, 'a post') }) + + test('timeline with non exitent tag', async () => { + const db = await makeDB() + + const data = await timelines.getPublicTimeline( + domain, + db, + timelines.LocalPreference.NotSet, + 0, + 'non-existent-tag' + ) + assert.equal(data.length, 0) + }) + + test('timeline tag', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + { + const note = await createStatus(domain, db, actor, 'test 1') + await insertHashtags(db, note, ['test', 'a']) + } + await sleep(10) + { + const note = await createStatus(domain, db, actor, 'test 2') + await insertHashtags(db, note, ['test', 'b']) + } + + { + const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, 'test') + assert.equal(data.length, 2) + assert.equal(data[0].content, 'test 2') + assert.equal(data[1].content, 'test 1') + } + + { + const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, 'a') + assert.equal(data.length, 1) + assert.equal(data[0].content, 'test 1') + } + }) }) }) diff --git a/functions/api/v1/timelines/tag/[tag].ts b/functions/api/v1/timelines/tag/[tag].ts new file mode 100644 index 0000000..b8b237c --- /dev/null +++ b/functions/api/v1/timelines/tag/[tag].ts @@ -0,0 +1,24 @@ +import type { Env } from 'wildebeest/backend/src/types/env' +import { cors } from 'wildebeest/backend/src/utils/cors' +import type { ContextData } from 'wildebeest/backend/src/types/context' +import * as timelines from 'wildebeest/backend/src/mastodon/timeline' + +const headers = { + ...cors(), + 'content-type': 'application/json; charset=utf-8', +} + +export const onRequest: PagesFunction = async ({ request, env, params }) => { + const domain = new URL(request.url).hostname + return handleRequest(env.DATABASE, request, domain, params.tag as string) +} + +export async function handleRequest(db: D1Database, request: Request, domain: string, tag: string): Promise { + const url = new URL(request.url) + if (url.searchParams.has('max_id')) { + return new Response(JSON.stringify([]), { headers }) + } + + const timeline = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, tag) + return new Response(JSON.stringify(timeline), { headers }) +}