kopia lustrzana https://github.com/cloudflare/wildebeest
handle hashtags in statuses
- by parsing them in enrichStatus - implementing a tag page to show their timeline resolves #345pull/348/head
rodzic
a97dbfa814
commit
a606e76093
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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<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 })
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
Ładowanie…
Reference in New Issue