diff --git a/backend/src/mastodon/microformats.ts b/backend/src/mastodon/microformats.ts index c724958..72a4ee9 100644 --- a/backend/src/mastodon/microformats.ts +++ b/backend/src/mastodon/microformats.ts @@ -13,14 +13,24 @@ function tag(name: string, content: string, attrs: Record = {}): const linkRegex = /(^|\s|\b)(https?:\/\/[-\w@:%._+~#=]{2,256}\.[a-z]{2,6}\b(?:[-\w@:%_+.~#?&/=]*))(\b|\s|$)/g const mentionedEmailRegex = /(^|\s|\b|\W)@(\w+(?:[.-]?\w+)+@\w+(?:[.-]?\w+)+(?:\.\w{2,63})+)(\b|\s|$)/g +const tagRegex = /(^|\s|\b|\W)#(\w{2,63})(\b|\s|$)/g -/// Transform a text status into a HTML status; enriching it with links / mentions. +// Transform a text status into a HTML status; enriching it with links / mentions. export function enrichStatus(status: string, mentions: Array): string { - const enrichedStatus = status + const anchorsPlaceholdersMap = new Map() + + const getLinkAnchorPlaceholder = (link: string) => { + const anchor = getLinkAnchor(link) + const placeholder = `%%%___-LINK-PLACEHOLDER-${crypto.randomUUID()}-__%%%` + anchorsPlaceholdersMap.set(placeholder, anchor) + return placeholder + } + + let enrichedStatus = status .replace( linkRegex, (_, matchPrefix: string, link: string, matchSuffix: string) => - `${matchPrefix}${getLinkAnchor(link)}${matchSuffix}` + `${matchPrefix}${getLinkAnchorPlaceholder(link)}${matchSuffix}` ) .replace(mentionedEmailRegex, (_, matchPrefix: string, email: string, matchSuffix: string) => { // ensure that the match is part of the mentions array @@ -33,6 +43,15 @@ export function enrichStatus(status: string, mentions: Array): string { // otherwise the match isn't valid and we don't add HTML return `${matchPrefix}${email}${matchSuffix}` }) + .replace( + tagRegex, + (_, matchPrefix: string, tag: string, matchSuffix: string) => + `${matchPrefix}${/^\d+$/.test(tag) ? `#${tag}` : getTagAnchor(tag)}${matchSuffix}` + ) + + for (const [placeholder, anchor] of anchorsPlaceholdersMap.entries()) { + enrichedStatus = enrichedStatus.replace(placeholder, anchor) + } return tag('p', enrichedStatus) } @@ -60,3 +79,12 @@ function getLinkAnchor(link: string) { return link } } + +function getTagAnchor(hashTag: string) { + try { + return tag('a', `#${hashTag}`, { href: `/tags/${hashTag.replace(/^#/, '')}`, class: 'status-link hashtag' }) + } catch (err: unknown) { + console.warn('failed to parse link', err) + return tag + } +} diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index 3773a4b..c5ca0ea 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -198,6 +198,51 @@ describe('Mastodon APIs', () => { assert.equal(enrichStatus(`@!@£${link}!!!`, []), `

@!@£${urlDisplayText}!!!

`) }) }) + + test('convert tags to HTML', async () => { + const tagsToTest = [ + { + tag: '#test', + expectedTagAnchor: '#test', + }, + { + tag: '#123_joke_123', + expectedTagAnchor: '#123_joke_123', + }, + { + tag: '#_123', + expectedTagAnchor: '#_123', + }, + { + tag: '#example:', + expectedTagAnchor: '#example:', + }, + { + tag: '#tagA#tagB', + expectedTagAnchor: + '#tagA#tagB', + }, + ] + + for (let i = 0, len = tagsToTest.length; i < len; i++) { + const { tag, expectedTagAnchor } = tagsToTest[i] + + assert.equal(enrichStatus(`hey ${tag} hi`, []), `

hey ${expectedTagAnchor} hi

`) + assert.equal(enrichStatus(`${tag} hi`, []), `

${expectedTagAnchor} hi

`) + assert.equal(enrichStatus(`${tag}\n\thein`, []), `

${expectedTagAnchor}\n\thein

`) + assert.equal(enrichStatus(`hey ${tag}`, []), `

hey ${expectedTagAnchor}

`) + assert.equal(enrichStatus(`${tag}`, []), `

${expectedTagAnchor}

`) + assert.equal(enrichStatus(`@!@£${tag}!!!`, []), `

@!@£${expectedTagAnchor}!!!

`) + } + }) + + test('ignore invalid tags', () => { + assert.equal(enrichStatus('tags cannot be empty like: #', []), `

tags cannot be empty like: #

`) + assert.equal( + enrichStatus('tags cannot contain only numbers like: #123', []), + `

tags cannot contain only numbers like: #123

` + ) + }) }) describe('Follow', () => { diff --git a/frontend/src/components/StickyHeader/StickyHeader.tsx b/frontend/src/components/StickyHeader/StickyHeader.tsx index 8266ffc..d0a3643 100644 --- a/frontend/src/components/StickyHeader/StickyHeader.tsx +++ b/frontend/src/components/StickyHeader/StickyHeader.tsx @@ -1,30 +1,37 @@ import { $, component$, Slot } from '@builder.io/qwik' import { useNavigate } from '@builder.io/qwik-city' -export default component$<{ withBackButton?: boolean }>(({ withBackButton }) => { - const nav = useNavigate() +export default component$<{ withBackButton?: boolean; backButtonPlacement?: 'start' | 'end' }>( + ({ withBackButton, backButtonPlacement = 'start' }) => { + const nav = useNavigate() - const goBack = $(() => { - if (window.history.length > 1) { - window.history.back() - } else { - nav('/explore') - } - }) + const goBack = $(() => { + if (window.history.length > 1) { + window.history.back() + } else { + nav('/explore') + } + }) - return ( -
-
- {!!withBackButton && ( -
- -
- )} - + const backButton = !withBackButton ? ( + // eslint-disable-next-line qwik/single-jsx-root + <> + ) : ( +
+
-
- ) -}) + ) + return ( +
+
+ {backButtonPlacement === 'start' && backButton} + + {backButtonPlacement === 'end' &&
{backButton}
} +
+
+ ) + } +) diff --git a/frontend/src/dummyData/statuses.ts b/frontend/src/dummyData/statuses.ts index 5125d94..a505309 100644 --- a/frontend/src/dummyData/statuses.ts +++ b/frontend/src/dummyData/statuses.ts @@ -31,7 +31,15 @@ const mastodonRawStatuses: MastodonStatus[] = [ content: 'A very simple update: all good!', account: ben, }), - generateDummyStatus({ content: '

Hi! My name is Rafael! 👋

', account: rafael, spoiler_text: 'who am I?' }), + generateDummyStatus({ + content: '

Hi! My name is Rafael! 👋

', + account: rafael, + spoiler_text: 'who am I?', + }), + generateDummyStatus({ + content: '

Hi! I made a funny! 🤭 #joke

', + account: george, + }), generateDummyStatus({ content: "

I'm Rafael and I am a web designer!

💪💪

", account: rafael, diff --git a/frontend/src/routes/(frontend)/tags/[tag]/index.tsx b/frontend/src/routes/(frontend)/tags/[tag]/index.tsx new file mode 100644 index 0000000..9766f46 --- /dev/null +++ b/frontend/src/routes/(frontend)/tags/[tag]/index.tsx @@ -0,0 +1,69 @@ +import { $, component$ } from '@builder.io/qwik' +import { DocumentHead, loader$ } from '@builder.io/qwik-city' +import { getDatabase } from 'wildebeest/backend/src/database' +import { getDomain } from 'wildebeest/backend/src/utils/getDomain' +import { handleRequest } from 'wildebeest/functions/api/v1/timelines/tag/[tag]' +import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel' +import StickyHeader from '~/components/StickyHeader/StickyHeader' +import { MastodonStatus } from '~/types' +import { getDocumentHead } from '~/utils/getDocumentHead' + +export const loader = loader$, { DATABASE: D1Database }>( + async ({ request, platform, params }) => { + const tag = params.tag + const response = await handleRequest(await getDatabase(platform), request, getDomain(request.url), tag) + const results = await response.text() + const statuses: MastodonStatus[] = JSON.parse(results) + return { tag, statuses } + } +) + +export default component$(() => { + const loaderData = loader() + + return ( + <> +
+ +

+ + {loaderData.value.tag} +

+
+ { + let statuses: MastodonStatus[] = [] + try { + const response = await fetch( + `/api/v1/timelines/tags/${loaderData.value.tag}/?offset=${numOfCurrentStatuses}` + ) + if (response.ok) { + const results = await response.text() + statuses = JSON.parse(results) + } + } catch { + /* empty */ + } + return statuses + })} + /> +
+ + ) +}) + +export const requestUrlLoader = loader$(async ({ request }) => request.url) + +export const head: DocumentHead = ({ resolveValue }) => { + const { tag } = resolveValue(loader) + const url = resolveValue(requestUrlLoader) + + return getDocumentHead({ + title: `#${tag} - Wildebeest`, + description: `#${tag} tag page - Wildebeest`, + og: { + url, + }, + }) +} diff --git a/functions/api/v1/timelines/tag/[tag].ts b/functions/api/v1/timelines/tag/[tag].ts index 9074fe4..7105dc4 100644 --- a/functions/api/v1/timelines/tag/[tag].ts +++ b/functions/api/v1/timelines/tag/[tag].ts @@ -3,6 +3,7 @@ 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' import { type Database, getDatabase } from 'wildebeest/backend/src/database' +import { getDomain } from 'wildebeest/backend/src/utils/getDomain' const headers = { ...cors(), @@ -10,16 +11,24 @@ const headers = { } export const onRequest: PagesFunction = async ({ request, env, params }) => { - const domain = new URL(request.url).hostname - return handleRequest(await getDatabase(env), request, domain, params.tag as string) + const url = new URL(request.url) + const { searchParams } = url + const offset = Number.parseInt(searchParams.get('offset') ?? '0') + return handleRequest(await getDatabase(env), request, getDomain(url), params.tag as string, offset) } -export async function handleRequest(db: Database, request: Request, domain: string, tag: string): Promise { +export async function handleRequest( + db: Database, + request: Request, + domain: string, + tag: string, + offset = 0 +): 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) + const timeline = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, offset, tag) return new Response(JSON.stringify(timeline), { headers }) } diff --git a/ui-e2e-tests/seo.spec.ts b/ui-e2e-tests/seo.spec.ts index 92cdafc..0c421d0 100644 --- a/ui-e2e-tests/seo.spec.ts +++ b/ui-e2e-tests/seo.spec.ts @@ -71,6 +71,17 @@ test.describe('Presence of appropriate SEO metadata across the application', () }) }) + test('in tag page', async ({ page }) => { + await page.goto('http://127.0.0.1:8788/tags/my-tag') + await checkPageSeoData(page, { + title: '#my-tag - Wildebeest', + description: '#my-tag tag page - Wildebeest', + ogType: 'website', + ogUrl: 'http://127.0.0.1:8788/tags/my-tag', + ogImage: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail', + }) + }) + // To unskip when we enable the about page test.skip('in about page', async ({ page }) => { await page.goto('http://127.0.0.1:8788/about')