feat: add reaction support with custom emoji handling and styling

This commit is contained in:
bunizao
2026-01-12 03:56:37 +08:00
parent 6a6a191bde
commit d56245169a
3 changed files with 245 additions and 9 deletions

View File

@@ -234,6 +234,12 @@
margin-right: 2px;
}
.tg-emoji {
width: 1.15em;
height: 1.15em;
vertical-align: -0.15em;
}
.sticker {
box-shadow: none;
border: none;
@@ -261,6 +267,50 @@
}
}
.reaction-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.reaction-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px 3px 6px;
font-size: 12px;
color: var(--secondary-color);
background: var(--code-background-color);
border: 1px solid var(--border-color);
border-radius: 999px;
}
.reaction-pill-paid {
background: rgba(255, 196, 0, 0.12);
border-color: rgba(255, 196, 0, 0.35);
color: #9a6a00;
}
.reaction-emoji {
display: inline-flex;
align-items: center;
font-size: 14px;
line-height: 1;
}
.reaction-emoji img {
width: 1em;
height: 1em;
display: block;
}
.reaction-count {
font-weight: 500;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
.tag-box {
flex-wrap: wrap;
}

View File

@@ -29,6 +29,26 @@ const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('H
</div>
</div>
{post.content.length > 0 && <div class={`text-box content`} set:html={post.content} />}
{
post.reactions?.length > 0 && (
<div class="reaction-list">
{post.reactions.map((reaction) => (
<span class={`reaction-pill${reaction.isPaid ? ' reaction-pill-paid' : ''}`}>
<span class="reaction-emoji">
{
reaction.isPaid
? '⭐'
: reaction.emojiImage
? <img src={reaction.emojiImage} alt={reaction.emoji || 'emoji'} loading="lazy" />
: reaction.emoji
}
</span>
<span class="reaction-count">{reaction.count}</span>
</span>
))}
</div>
)
}
{
post.tags.length > 0 && (
<div class="tag-box" style={post.content.length === 0 ? 'padding-top: 30px;' : ''}>

View File

@@ -13,6 +13,113 @@ const cache = new LRUCache({
},
})
// Common emoji-id to standard emoji fallback mapping
const EMOJI_ID_FALLBACK = {
'5368324170671202286': '\uD83D\uDC4D',
'5427127139151397446': '\uD83E\uDD1D',
'5388841349703284277': '\uD83D\uDD25',
'5265077361648368841': '\u2764\uFE0F',
'5388967613151851494': '\uD83C\uDF89',
'5881813392380923308': '\uD83D\uDE0D',
'5456669990092545624': '\uD83D\uDCAF',
'5384108682290152083': '\uD83D\uDC4F',
'5449800250032143374': '\uD83D\uDE02',
'5006239808935167310': '\uD83D\uDE97',
'5472105307985419058': '\u261D\uFE0F',
'5375338737028841420': '\uD83E\uDD29',
}
// Normalize emoji variants (e.g., heart variants)
function normalizeEmoji(emoji) {
const emojiMap = {
'\u2764': '\u2764\uFE0F',
'\u263A': '\u263A\uFE0F',
'\u2639': '\u2639\uFE0F',
'\u2665': '\u2764\uFE0F',
}
return emojiMap[emoji] || emoji
}
const customEmojiFallback = new Map(Object.entries(EMOJI_ID_FALLBACK))
const customEmojiCache = new LRUCache({
ttl: 1000 * 60 * 60 * 24, // 24 hours
max: 500,
})
function getEmojiFallback(emojiId) {
if (!emojiId)
return ''
return customEmojiFallback.get(emojiId) || ''
}
function setEmojiFallback(emojiId, emoji) {
if (!emojiId || !emoji)
return
customEmojiFallback.set(emojiId, emoji)
}
function extractTgEmojiFallback($, emojiEl) {
const nestedEmoji = $(emojiEl).find('.emoji b').text().trim()
if (nestedEmoji) {
return normalizeEmoji(nestedEmoji)
}
const attrEmoji = $(emojiEl).attr('emoji') ?? $(emojiEl).attr('alt') ?? ''
if (attrEmoji) {
return normalizeEmoji(attrEmoji.trim())
}
return ''
}
async function getCustomEmojiImage(emojiId, staticProxy = '') {
if (!emojiId)
return null
const cached = customEmojiCache.get(emojiId)
if (cached?.thumb) {
return `${staticProxy}${cached.thumb}`
}
try {
const data = await $fetch(`https://t.me/i/emoji/${emojiId}.json`)
if (data) {
customEmojiCache.set(emojiId, data)
if (data.thumb) {
return `${staticProxy}${data.thumb}`
}
}
}
catch (error) {
console.error('Failed to load custom emoji metadata', emojiId, error)
}
return null
}
async function hydrateTgEmoji($, content, { staticProxy } = {}) {
const emojiNodes = $(content).find('tg-emoji')?.toArray() ?? []
if (!emojiNodes.length)
return
await Promise.all(emojiNodes.map(async (emojiEl) => {
const emojiId = $(emojiEl).attr('emoji-id')
const nestedFallback = extractTgEmojiFallback($, emojiEl)
const fallback = nestedFallback || getEmojiFallback(emojiId)
if (emojiId && fallback) {
setEmojiFallback(emojiId, fallback)
}
if (fallback) {
$(emojiEl).text(fallback)
return
}
if (!emojiId)
return
const imageUrl = await getCustomEmojiImage(emojiId, staticProxy)
if (imageUrl) {
const imageMarkup = `<img class="tg-emoji" src="${imageUrl}" alt="" loading="lazy" />`
$(emojiEl).replaceWith(imageMarkup)
}
}))
}
function getVideoStickers($, item, { staticProxy, index }) {
return $(item).find('.js-videosticker_video')?.map((_index, video) => {
const url = $(video)?.attr('src')
@@ -101,7 +208,8 @@ function getReply($, item, { channel }) {
return $.html(reply)
}
function modifyHTMLContent($, content, { index } = {}) {
async function modifyHTMLContent($, content, { index, staticProxy } = {}) {
await hydrateTgEmoji($, content, { staticProxy })
$(content).find('.emoji')?.removeAttr('style')
$(content).find('a')?.each((_index, a) => {
$(a)?.attr('title', $(a)?.text())?.removeAttr('onclick')
@@ -137,11 +245,66 @@ function modifyHTMLContent($, content, { index } = {}) {
return content
}
function getPost($, item, { channel, staticProxy, index = 0 }) {
async function getReactions($, item, staticProxy) {
const reactions = []
const reactionNodes = $(item).find('.tgme_widget_message_reactions .tgme_reaction').toArray()
for (const reaction of reactionNodes) {
const isPaid = $(reaction).hasClass('tgme_reaction_paid')
let emoji = ''
let emojiId
let emojiImage
const standardEmoji = $(reaction).find('.emoji b')
if (standardEmoji.length) {
emoji = normalizeEmoji(standardEmoji.text().trim())
}
const tgEmoji = $(reaction).find('tg-emoji')
if (tgEmoji.length && !emoji) {
emojiId = tgEmoji.attr('emoji-id')
if (emojiId) {
const nestedFallback = extractTgEmojiFallback($, tgEmoji.get(0))
emoji = nestedFallback || getEmojiFallback(emojiId)
if (emoji) {
setEmojiFallback(emojiId, emoji)
}
else {
const imageUrl = await getCustomEmojiImage(emojiId, staticProxy)
if (imageUrl) {
emojiImage = imageUrl
}
}
}
}
if (isPaid && !emoji && !emojiImage) {
emoji = '\u2B50'
}
const clone = $(reaction).clone()
clone.find('.emoji, tg-emoji, i').remove()
const count = clone.text().trim()
if (count) {
reactions.push({
emoji: emoji || (emojiImage ? '' : '\uD83D\uDC4D'),
emojiId,
emojiImage,
count,
isPaid,
})
}
}
return reactions
}
async function getPost($, item, { channel, staticProxy, index = 0 }) {
item = item ? $(item).find('.tgme_widget_message') : $('.tgme_widget_message')
const content = $(item).find('.js-message_reply_text')?.length > 0
? modifyHTMLContent($, $(item).find('.tgme_widget_message_text.js-message_text'), { index })
: modifyHTMLContent($, $(item).find('.tgme_widget_message_text'), { index })
? await modifyHTMLContent($, $(item).find('.tgme_widget_message_text.js-message_text'), { index, staticProxy })
: await modifyHTMLContent($, $(item).find('.tgme_widget_message_text'), { index, staticProxy })
const title = content?.text()?.match(/^.*?(?=[。\n]|http\S)/g)?.[0] ?? content?.text() ?? ''
const id = $(item).attr('data-post')?.replace(new RegExp(`${channel}/`, 'i'), '')
@@ -179,6 +342,7 @@ function getPost($, item, { channel, staticProxy, index = 0 }) {
}
return `${p1}${staticProxy}${p2}`
}),
reactions: await getReactions($, item, staticProxy),
}
}
@@ -221,19 +385,21 @@ export async function getChannelInfo(Astro, { before = '', after = '', q = '', t
const $ = cheerio.load(html, {}, false)
if (id) {
const post = getPost($, null, { channel, staticProxy })
const post = await getPost($, null, { channel, staticProxy })
cache.set(cacheKey, post)
return post
}
const posts = $('.tgme_channel_history .tgme_widget_message_wrap')?.map((index, item) => {
return getPost($, item, { channel, staticProxy, index })
})?.get()?.reverse().filter(post => ['text'].includes(post.type) && post.id && post.content)
const posts = (await Promise.all(
$('.tgme_channel_history .tgme_widget_message_wrap')?.map((index, item) => {
return getPost($, item, { channel, staticProxy, index })
})?.get() ?? [],
))?.reverse().filter(post => ['text'].includes(post.type) && post.id && post.content)
const channelInfo = {
posts,
title: $('.tgme_channel_info_header_title')?.text(),
description: $('.tgme_channel_info_description')?.text(),
descriptionHTML: modifyHTMLContent($, $('.tgme_channel_info_description'))?.html(),
descriptionHTML: (await modifyHTMLContent($, $('.tgme_channel_info_description'), { staticProxy }))?.html(),
avatar: $('.tgme_page_photo_image img')?.attr('src'),
}