Skip to content

Commit 7130066

Browse files
committed
Enhance message rendering to support emoji-only messages
1 parent 1f5e094 commit 7130066

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

web/src/components/chat/message-markdown.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
MessageBodyWithHandles,
1515
type ChatUserRef,
1616
} from '@/components/chat/user-handle'
17+
import { isEmojiOnlyMessage } from '@/lib/emoji'
1718
import { cn } from '@/lib/utils'
1819

1920
function withMentions(
@@ -91,6 +92,7 @@ export function MessageMarkdown({
9192
token?: string
9293
}) {
9394
const mentionProps = { usersByHandle, serverUrl, token }
95+
const emojiOnly = isEmojiOnlyMessage(body)
9496
const components: Components = {
9597
p: ({ children }) => (
9698
<p className="mb-1 last:mb-0">
@@ -213,7 +215,13 @@ export function MessageMarkdown({
213215
}
214216

215217
return (
216-
<div className={cn('text-sm text-muted-foreground break-words', className)}>
218+
<div
219+
className={cn(
220+
'text-sm text-muted-foreground break-words',
221+
emojiOnly && 'text-[1.75rem] leading-none',
222+
className,
223+
)}
224+
>
217225
<ReactMarkdown
218226
remarkPlugins={[remarkGfm, remarkBreaks]}
219227
components={components}

web/src/lib/emoji.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,71 @@ export function matchEmoticonAt(
207207
return null
208208
}
209209

210+
const SHORTCODE_AT_START_RE = /^:([a-zA-Z0-9_+-]+):/
211+
const graphemeSegmenter = new Intl.Segmenter(undefined, {
212+
granularity: 'grapheme',
213+
})
214+
215+
function isEmojiGrapheme(grapheme: string): boolean {
216+
if (/\p{Extended_Pictographic}/u.test(grapheme)) return true
217+
if (/^\p{Regional_Indicator}{2}$/u.test(grapheme)) return true
218+
if (/^[#*0-9]\uFE0F?\u20E3$/u.test(grapheme)) return true
219+
return false
220+
}
221+
222+
function firstGrapheme(text: string): string {
223+
const first = graphemeSegmenter.segment(text)[Symbol.iterator]().next().value
224+
return first?.segment ?? text[0] ?? ''
225+
}
226+
227+
/** True when the body is only emoji, shortcodes, emoticons, and whitespace. */
228+
export function isEmojiOnlyMessage(
229+
body: string,
230+
options?: { customByName?: Map<string, CustomEmoji> },
231+
): boolean {
232+
const text = body.trim()
233+
if (!text) return false
234+
235+
let index = 0
236+
let found = false
237+
while (index < text.length) {
238+
const ch = text[index]
239+
if (ch !== undefined && /\s/.test(ch)) {
240+
index += 1
241+
continue
242+
}
243+
244+
const shortcode = SHORTCODE_AT_START_RE.exec(text.slice(index))
245+
if (shortcode?.[1] && resolveEmoji(shortcode[1], options)) {
246+
found = true
247+
index += shortcode[0].length
248+
const skin = SHORTCODE_AT_START_RE.exec(text.slice(index))
249+
if (skin?.[1] && isSkinToneShortcode(skin[1]) !== null) {
250+
index += skin[0].length
251+
}
252+
continue
253+
}
254+
255+
const emoticon = matchEmoticonAt(text, index)
256+
if (emoticon) {
257+
found = true
258+
index += emoticon.token.length
259+
continue
260+
}
261+
262+
const grapheme = firstGrapheme(text.slice(index))
263+
if (grapheme && isEmojiGrapheme(grapheme)) {
264+
found = true
265+
index += grapheme.length
266+
continue
267+
}
268+
269+
return false
270+
}
271+
272+
return found
273+
}
274+
210275
export function getEmojiCatalog(): EmojiCategory[] {
211276
if (catalogCache) return catalogCache
212277

0 commit comments

Comments
 (0)