Skip to content

Commit 771bb40

Browse files
authored
fix(trending): strip post emoji on the server only (#474)
1 parent 8b39bff commit 771bb40

5 files changed

Lines changed: 68 additions & 57 deletions

File tree

β€Žlayers/marketing/app/components/TrendingPostCard.vueβ€Ž

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import type { TrendingPost } from '#shared/trending-range'
33
import { avatarProxyUrl } from '#shared/image-proxy'
4-
import { postExcerpt, unstyled } from '#shared/trending-post'
4+
import { postExcerpt } from '#shared/trending-post'
55
66
/**
77
* One post, laid out the way people already read posts: who said it, when,
@@ -28,8 +28,6 @@ const LINES = 4
2828
* mention past this moves the excerpt to it; see `postExcerpt`.
2929
*/
3030
const segments = computed(() => postExcerpt({ text: post.text, names, budget: LINES * 42 }))
31-
/** The name in the site's type, like the post under it. A name of emoji alone shows the handle only. */
32-
const authorName = computed(() => (post.authorName ? unstyled(post.authorName).trim() || null : null))
3331
const network = computed(() => (post.platform === 'bsky' ? 'Bluesky' : 'X'))
3432
3533
function likesLabel(count: number): string {
@@ -56,7 +54,7 @@ function likesLabel(count: number): string {
5654
decoding="async"
5755
>
5856
<span class="trending-post__byline">
59-
<span v-if="authorName" class="trending-post__name">{{ authorName }}</span>
57+
<span v-if="post.authorName" class="trending-post__name">{{ post.authorName }}</span>
6058
<span class="trending-post__handle">@{{ post.handle }}</span>
6159
</span>
6260
<span class="trending-post__meta">{{ post.when }}</span>

β€Žshared/server/trending-skills.tsβ€Ž

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,14 +222,44 @@ function compareFit(a: QuoteFit, b: QuoteFit): number {
222222
return Number(a.automated) - Number(b.automated) || a.breadth - b.breadth
223223
}
224224

225+
/**
226+
* Emoji, with the joiners, variation selectors, skin tones, flag letters and
227+
* keycap marks that build them. A keycap keeps its digit.
228+
*/
229+
const EMOJI = /[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}\p{Variation_Selector}\p{Enclosing_Mark}\u200D]/gu
230+
231+
/**
232+
* Post text as plain letters, without emoji, before it leaves the server.
233+
*
234+
* A quote is testimony set in the site's type, and emoji rows such as
235+
* `πŸŽ‰ Celebrating πŸŽ‰` were the loudest thing in a muted line. `NFKC` folds
236+
* the letter styles posts use for emphasis, such as mathematical bold, back
237+
* to plain letters.
238+
*
239+
* Done here, once, and never in the page. Which characters count as emoji
240+
* depends on the engine's Unicode tables: Node 24 and Chrome 145 disagree
241+
* about `β˜…`. Stripping on both sides rendered different quote text on the
242+
* server and in the browser, and every board failed hydration.
243+
*/
244+
function plainText(text: string): string {
245+
return text
246+
.normalize('NFKC')
247+
.replace(EMOJI, '')
248+
.split('\n')
249+
.map(line => line.replace(/[ \t\xA0]+/g, ' ').trim())
250+
.join('\n')
251+
.trim()
252+
}
253+
225254
function toEvidence(row: MentionRow): TrendingSkillEvidence {
226255
return {
227256
postId: row.post_id,
228257
url: postUrl(row),
229258
authorHandle: row.author_handle,
230-
authorName: row.author_name ?? null,
259+
// A name of emoji alone becomes no name, and the card shows the handle.
260+
authorName: (row.author_name && plainText(row.author_name)) || null,
231261
authorAvatar: row.author_avatar ?? null,
232-
text: row.text_extract,
262+
text: plainText(row.text_extract),
233263
postedAt: row.posted_at,
234264
favouriteCount: row.favourite_count,
235265
platform: row.platform,

β€Žshared/trending-post.tsβ€Ž

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,34 +40,17 @@ const SHORT_LINK = /\s*\(\s*https?:\/\/t\.co\/\w+\s*\)|\s*(?:[-–—:]\s*)?http
4040
/** Scheme and `www.` on any other link. The host and path are the useful part. */
4141
const LINK_PREFIX = /https?:\/\/(?:www\.)?/g
4242

43-
/**
44-
* Emoji, with the joiners, variation selectors, skin tones, flag letters and
45-
* keycap marks that build them. A keycap keeps its digit.
46-
*
47-
* A quote is testimony set in the site's type. Emoji rows such as
48-
* `πŸŽ‰ Celebrating πŸŽ‰ πŸ“¦` read as the poster's styling, and they were the
49-
* loudest thing in a muted line.
50-
*/
51-
const EMOJI = /[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}\p{Variation_Selector}\p{Enclosing_Mark}\u200D]/gu
52-
5343
function escapeRegExp(value: string): string {
5444
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5545
}
5646

5747
/**
58-
* Text as plain letters, without emoji.
59-
*
60-
* `NFKC` folds the Unicode letter styles posts use for emphasis, such as
61-
* mathematical bold `π˜€π—΅π—Όπ˜„`, back to plain letters. Those rendered as bold text
62-
* in the quote, and a Skill name written in them never matched. Spacing is
63-
* left to the caller.
48+
* No Unicode property escapes or normalization here. Their tables differ
49+
* between engines, and this runs on the server and in the browser; emoji
50+
* and letter styles leave the post on the server, in `trending-skills.ts`.
6451
*/
65-
export function unstyled(text: string): string {
66-
return text.normalize('NFKC').replace(EMOJI, '')
67-
}
68-
6952
function tidy(text: string): string {
70-
return unstyled(text)
53+
return text
7154
.replace(/\r\n?/g, '\n')
7255
.replace(SHORT_LINK, '')
7356
.replace(LINK_PREFIX, '')

β€Žtest/unit/trending-post.test.tsβ€Ž

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @vitest-environment node
22
import type { PostSegment } from '../../shared/trending-post'
33
import { describe, expect, it } from 'vitest'
4-
import { postExcerpt, relativeDay, unstyled } from '../../shared/trending-post'
4+
import { postExcerpt, relativeDay } from '../../shared/trending-post'
55

66
function plain(segments: PostSegment[]): string {
77
return segments.map(s => s.value).join('')
@@ -86,28 +86,9 @@ describe('postExcerpt', () => {
8686
expect(text.length).toBeLessThan(80)
8787
})
8888

89-
it('drops emoji and keeps the words around them', () => {
90-
const excerpt = postExcerpt({
91-
text: 'πŸŽ‰ Celebrating πŸŽ‰ (500+ new stars) πŸ“¦ blader / humanizer ⭐️ πŸ‘¨β€πŸ‘©β€πŸ‘§ πŸ‡¦πŸ‡Ί',
92-
names: [],
93-
budget: 200,
94-
})
95-
expect(plain(excerpt)).toBe('Celebrating (500+ new stars) blader / humanizer')
96-
})
97-
98-
it('keeps the digit of a keycap emoji', () => {
99-
const excerpt = postExcerpt({ text: '1️⃣ Install\n2️⃣ Prompt', names: [], budget: 200 })
100-
expect(plain(excerpt)).toBe('1 Install\n2 Prompt')
101-
})
102-
103-
it('reads Unicode bold and italic letters as plain text', () => {
104-
const excerpt = postExcerpt({
105-
text: 'as long as you π˜€π—΅π—Όπ˜„ π—Άπ˜ π˜π—Όπ—½-π˜π—Άπ—²π—Ώ design with 𝙝π™ͺπ™’π™–π™£π™žπ™―π™šπ™§',
106-
names: ['humanizer'],
107-
budget: 200,
108-
})
109-
expect(plain(excerpt)).toBe('as long as you show it top-tier design with humanizer')
110-
expect(mentions(excerpt)).toEqual(['humanizer'])
89+
it('keeps every character it is given, so the server and the browser cut the same text', () => {
90+
const text = '33.5kβ˜… πŸŽ‰ π˜€π—΅π—Όπ˜„ β˜†β˜†'
91+
expect(plain(postExcerpt({ text, names: [], budget: 200 }))).toBe(text)
11192
})
11293

11394
it('returns the tidied text as one segment when no name matches', () => {
@@ -116,12 +97,6 @@ describe('postExcerpt', () => {
11697
})
11798
})
11899

119-
describe('unstyled', () => {
120-
it('returns a display name as plain letters without emoji', () => {
121-
expect(unstyled('𝗝𝗼𝗡𝗻 ⚑️').trim()).toBe('John')
122-
})
123-
})
124-
125100
describe('relativeDay', () => {
126101
const NOW = 1_760_000_000
127102

β€Žtest/unit/trending-skills.test.tsβ€Ž

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function mention(input: {
3232
canonical?: string
3333
avatar?: string
3434
name?: string
35+
text?: string
3536
}): string {
3637
db().raw.prepare(
3738
`INSERT OR IGNORE INTO repos (owner, repo) VALUES (?, ?)`,
@@ -57,7 +58,7 @@ function mention(input: {
5758
`a-${input.handle}`,
5859
input.handle,
5960
input.name ?? null,
60-
`post about /${input.slug}`,
61+
input.text ?? `post about /${input.slug}`,
6162
postedAt,
6263
postedAt,
6364
input.likes ?? 1,
@@ -276,6 +277,30 @@ describe('loadTrendingSkills presentation', () => {
276277
})
277278
})
278279

280+
describe('loadTrendingSkills post text', () => {
281+
it('ships post text and author names as plain letters without emoji', async () => {
282+
mention({
283+
owner: 'a',
284+
repo: 'r',
285+
slug: 's',
286+
handle: 'styled',
287+
name: '𝗔𝗹𝗢 ⚑️',
288+
text: 'πŸŽ‰ Celebrating πŸŽ‰ /s\n1️⃣ π˜€π—΅π—Όπ˜„ it ⭐️ πŸ‘¨β€πŸ‘©β€πŸ‘§ πŸ‡¦πŸ‡Ί',
289+
})
290+
291+
const [skill] = await loadTrendingSkills({ db: db().db, now: NOW })
292+
expect(skill?.evidence?.text).toBe('Celebrating /s\n1 show it')
293+
expect(skill?.evidence?.authorName).toBe('Ali')
294+
})
295+
296+
it('drops a name made of emoji alone', async () => {
297+
mention({ owner: 'a', repo: 'r', slug: 's', handle: 'sparkles', name: '✨✨' })
298+
299+
const [skill] = await loadTrendingSkills({ db: db().db, now: NOW })
300+
expect(skill?.evidence?.authorName).toBeNull()
301+
})
302+
})
303+
279304
describe('loadTrendingSkills posts beyond the quote', () => {
280305
it('carries one post per other author and never repeats the quoted one', async () => {
281306
mention({ owner: 'a', repo: 'r', slug: 's', handle: 'quoted', likes: 90, name: 'Quoted Dev' })

0 commit comments

Comments
Β (0)