Skip to content

Commit

Permalink
handle hashtags in statuses
Browse files Browse the repository at this point in the history
 - by parsing them in enrichStatus
 - implementing a tag page to show their timeline

resolves #345
  • Loading branch information
dario-piotrowicz committed Feb 28, 2023
1 parent a97dbfa commit a606e76
Show file tree
Hide file tree
Showing 7 changed files with 209 additions and 32 deletions.
34 changes: 31 additions & 3 deletions backend/src/mastodon/microformats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ function tag(name: string, content: string, attrs: Record<string, string> = {}):

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<Actor>): string {
const enrichedStatus = status
const anchorsPlaceholdersMap = new Map<string, string>()

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
Expand All @@ -33,6 +43,15 @@ export function enrichStatus(status: string, mentions: Array<Actor>): 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)
}
Expand Down Expand Up @@ -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
}
}
45 changes: 45 additions & 0 deletions backend/test/mastodon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,51 @@ describe('Mastodon APIs', () => {
assert.equal(enrichStatus(`@!@£${link}!!!`, []), `<p>@!@£<a href="${link}">${urlDisplayText}</a>!!!</p>`)
})
})

test('convert tags to HTML', async () => {
const tagsToTest = [
{
tag: '#test',
expectedTagAnchor: '<a href="/tags/test" class="status-link hashtag">#test</a>',
},
{
tag: '#123_joke_123',
expectedTagAnchor: '<a href="/tags/123_joke_123" class="status-link hashtag">#123_joke_123</a>',
},
{
tag: '#_123',
expectedTagAnchor: '<a href="/tags/_123" class="status-link hashtag">#_123</a>',
},
{
tag: '#example:',
expectedTagAnchor: '<a href="/tags/example" class="status-link hashtag">#example</a>:',
},
{
tag: '#tagA#tagB',
expectedTagAnchor:
'<a href="/tags/tagA" class="status-link hashtag">#tagA</a><a href="/tags/tagB" class="status-link hashtag">#tagB</a>',
},
]

for (let i = 0, len = tagsToTest.length; i < len; i++) {
const { tag, expectedTagAnchor } = tagsToTest[i]

assert.equal(enrichStatus(`hey ${tag} hi`, []), `<p>hey ${expectedTagAnchor} hi</p>`)
assert.equal(enrichStatus(`${tag} hi`, []), `<p>${expectedTagAnchor} hi</p>`)
assert.equal(enrichStatus(`${tag}\n\thein`, []), `<p>${expectedTagAnchor}\n\thein</p>`)
assert.equal(enrichStatus(`hey ${tag}`, []), `<p>hey ${expectedTagAnchor}</p>`)
assert.equal(enrichStatus(`${tag}`, []), `<p>${expectedTagAnchor}</p>`)
assert.equal(enrichStatus(`@!@£${tag}!!!`, []), `<p>@!@£${expectedTagAnchor}!!!</p>`)
}
})

test('ignore invalid tags', () => {
assert.equal(enrichStatus('tags cannot be empty like: #', []), `<p>tags cannot be empty like: #</p>`)
assert.equal(
enrichStatus('tags cannot contain only numbers like: #123', []),
`<p>tags cannot contain only numbers like: #123</p>`
)
})
})

describe('Follow', () => {
Expand Down
55 changes: 31 additions & 24 deletions frontend/src/components/StickyHeader/StickyHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header class="bg-wildebeest-900 sticky top-[3.9rem] xl:top-0 xl:pt-2.5 z-10">
<div class="flex bg-wildebeest-700 xl:rounded-t overflow-hidden">
{!!withBackButton && (
<div class="flex justify-between items-center bg-wildebeest-700">
<button class="text-semi no-underline text-wildebeest-vibrant-400 bg-transparent p-4" onClick$={goBack}>
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
<span class="hover:underline">Back</span>
</button>
</div>
)}
<Slot />
const backButton = !withBackButton ? (
// eslint-disable-next-line qwik/single-jsx-root
<></>
) : (
<div class="flex justify-between items-center bg-wildebeest-700">
<button class="text-semi no-underline text-wildebeest-vibrant-400 bg-transparent p-4" onClick$={goBack}>
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
<span class="hover:underline">Back</span>
</button>
</div>
</header>
)
})
)
return (
<header class="bg-wildebeest-900 sticky top-[3.9rem] xl:top-0 xl:pt-2.5 z-10">
<div class="flex bg-wildebeest-700 xl:rounded-t overflow-hidden">
{backButtonPlacement === 'start' && backButton}
<Slot />
{backButtonPlacement === 'end' && <div class="ml-auto">{backButton}</div>}
</div>
</header>
)
}
)
10 changes: 9 additions & 1 deletion frontend/src/dummyData/statuses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ const mastodonRawStatuses: MastodonStatus[] = [
content: '<span>A very simple update: all good!</span>',
account: ben,
}),
generateDummyStatus({ content: '<p>Hi! My name is Rafael! 👋</p>', account: rafael, spoiler_text: 'who am I?' }),
generateDummyStatus({
content: '<p>Hi! My name is Rafael! 👋</p>',
account: rafael,
spoiler_text: 'who am I?',
}),
generateDummyStatus({
content: '<p>Hi! I made a funny! 🤭 <a href="/tags/joke" class="status-link hashtag">#joke</a></p>',
account: george,
}),
generateDummyStatus({
content: "<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
account: rafael,
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/routes/(frontend)/tags/[tag]/index.tsx
Original file line number Diff line number Diff line change
@@ -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$<Promise<{ tag: string; statuses: MastodonStatus[] }>, { 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 (
<>
<div class="flex flex-col flex-1">
<StickyHeader withBackButton backButtonPlacement="end">
<h2 class="text-reg text-md m-0 p-4 flex bg-wildebeest-700">
<i class="fa fa-hashtag fa-fw mr-3 w-5 leading-tight inline-block" />
<span>{loaderData.value.tag}</span>
</h2>
</StickyHeader>
<StatusesPanel
initialStatuses={loaderData.value.statuses}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
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
})}
/>
</div>
</>
)
})

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,
},
})
}
17 changes: 13 additions & 4 deletions functions/api/v1/timelines/tag/[tag].ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,32 @@ 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(),
'content-type': 'application/json; charset=utf-8',
}

export const onRequest: PagesFunction<Env, any, ContextData> = 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<Response> {
export async function handleRequest(
db: Database,
request: Request,
domain: string,
tag: string,
offset = 0
): Promise<Response> {
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 })
}
11 changes: 11 additions & 0 deletions ui-e2e-tests/seo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit a606e76

Please sign in to comment.