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 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 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 {
|
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(
|
.replace(
|
||||||
linkRegex,
|
linkRegex,
|
||||||
(_, matchPrefix: string, link: string, matchSuffix: string) =>
|
(_, matchPrefix: string, link: string, matchSuffix: string) =>
|
||||||
`${matchPrefix}${getLinkAnchor(link)}${matchSuffix}`
|
`${matchPrefix}${getLinkAnchorPlaceholder(link)}${matchSuffix}`
|
||||||
)
|
)
|
||||||
.replace(mentionedEmailRegex, (_, matchPrefix: string, email: string, matchSuffix: string) => {
|
.replace(mentionedEmailRegex, (_, matchPrefix: string, email: string, matchSuffix: string) => {
|
||||||
// ensure that the match is part of the mentions array
|
// 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
|
// otherwise the match isn't valid and we don't add HTML
|
||||||
return `${matchPrefix}${email}${matchSuffix}`
|
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)
|
return tag('p', enrichedStatus)
|
||||||
}
|
}
|
||||||
|
@ -60,3 +79,12 @@ function getLinkAnchor(link: string) {
|
||||||
return link
|
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>`)
|
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', () => {
|
describe('Follow', () => {
|
||||||
|
|
|
@ -1,30 +1,37 @@
|
||||||
import { $, component$, Slot } from '@builder.io/qwik'
|
import { $, component$, Slot } from '@builder.io/qwik'
|
||||||
import { useNavigate } from '@builder.io/qwik-city'
|
import { useNavigate } from '@builder.io/qwik-city'
|
||||||
|
|
||||||
export default component$<{ withBackButton?: boolean }>(({ withBackButton }) => {
|
export default component$<{ withBackButton?: boolean; backButtonPlacement?: 'start' | 'end' }>(
|
||||||
const nav = useNavigate()
|
({ withBackButton, backButtonPlacement = 'start' }) => {
|
||||||
|
const nav = useNavigate()
|
||||||
|
|
||||||
const goBack = $(() => {
|
const goBack = $(() => {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
} else {
|
} else {
|
||||||
nav('/explore')
|
nav('/explore')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const backButton = !withBackButton ? (
|
||||||
<header class="bg-wildebeest-900 sticky top-[3.9rem] xl:top-0 xl:pt-2.5 z-10">
|
// eslint-disable-next-line qwik/single-jsx-root
|
||||||
<div class="flex bg-wildebeest-700 xl:rounded-t overflow-hidden">
|
<></>
|
||||||
{!!withBackButton && (
|
) : (
|
||||||
<div class="flex justify-between items-center bg-wildebeest-700">
|
<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}>
|
<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" />
|
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
|
||||||
<span class="hover:underline">Back</span>
|
<span class="hover:underline">Back</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Slot />
|
|
||||||
</div>
|
</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>',
|
content: '<span>A very simple update: all good!</span>',
|
||||||
account: ben,
|
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({
|
generateDummyStatus({
|
||||||
content: "<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
|
content: "<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
|
||||||
account: rafael,
|
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 type { ContextData } from 'wildebeest/backend/src/types/context'
|
||||||
import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
|
import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
|
||||||
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
|
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
|
||||||
|
import { getDomain } from 'wildebeest/backend/src/utils/getDomain'
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...cors(),
|
...cors(),
|
||||||
|
@ -10,16 +11,24 @@ const headers = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request, env, params }) => {
|
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request, env, params }) => {
|
||||||
const domain = new URL(request.url).hostname
|
const url = new URL(request.url)
|
||||||
return handleRequest(await getDatabase(env), request, domain, params.tag as string)
|
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)
|
const url = new URL(request.url)
|
||||||
if (url.searchParams.has('max_id')) {
|
if (url.searchParams.has('max_id')) {
|
||||||
return new Response(JSON.stringify([]), { headers })
|
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 })
|
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
|
// To unskip when we enable the about page
|
||||||
test.skip('in about page', async ({ page }) => {
|
test.skip('in about page', async ({ page }) => {
|
||||||
await page.goto('http://127.0.0.1:8788/about')
|
await page.goto('http://127.0.0.1:8788/about')
|
||||||
|
|
Ładowanie…
Reference in New Issue