diff --git a/.env.example b/.env.example
index 7c3277a..cde8f99 100644
--- a/.env.example
+++ b/.env.example
@@ -23,6 +23,7 @@ STATIC_PROXY=""
GOOGLE_SEARCH_SITE=""
TAGS=""
COMMENTS=""
+REACTIONS=""
LINKS=""
NAVS=""
-RSS_BEAUTIFY=""
\ No newline at end of file
+RSS_BEAUTIFY=""
diff --git a/README.md b/README.md
index 23073ea..9d1544f 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,9 @@ TAGS=tag1,tag2,tag3
## Show comments
COMMENTS=true
+## Show reactions
+REACTIONS=true
+
## List of links in the Links page, Separate using commas and semicolons
LINKS=Title1,URL1;Title2,URL3;Title3,URL3;
diff --git a/README.zh-cn.md b/README.zh-cn.md
index 4fbc3e5..33fe473 100644
--- a/README.zh-cn.md
+++ b/README.zh-cn.md
@@ -129,6 +129,9 @@ TAGS=标签A,标签B,标签C
## 展示评论
COMMENTS=true
+## 展示 Reactions
+REACTIONS=true
+
## 链接页面中的超链接, 使用英文逗号和分号分割
LINKS=Title1,URL1;Title2,URL3;Title3,URL3;
diff --git a/src/assets/item.css b/src/assets/item.css
index b84d0f0..3721e19 100644
--- a/src/assets/item.css
+++ b/src/assets/item.css
@@ -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: 0;
+}
+
+.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;
}
diff --git a/src/assets/style.css b/src/assets/style.css
index 5df7c21..83bc7af 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -327,6 +327,17 @@ audio::-webkit-media-controls-panel {
margin-left: 3px;
}
+.reaction-box {
+ border-left: 2px solid var(--border-color);
+ padding: 6px 0px 24px 30px;
+ margin-left: 3px;
+}
+
+.text-box + .reaction-box {
+ margin-top: -12px;
+ padding-top: 0px;
+}
+
.text-box p:first-child {
margin-top: 0px;
}
diff --git a/src/components/item.astro b/src/components/item.astro
index 15035d4..42f494f 100644
--- a/src/components/item.astro
+++ b/src/components/item.astro
@@ -14,6 +14,7 @@ const { post, isItem } = Astro.props
const channel = getEnv(import.meta.env, Astro, 'CHANNEL')
const COMMENTS = getEnv(import.meta.env, Astro, 'COMMENTS')
+const REACTIONS = getEnv(import.meta.env, Astro, 'REACTIONS')
const datetime = dayjs(post.datetime).tz(timezone)
const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('HH:mm · ll · ddd') : datetime.fromNow()
@@ -29,6 +30,28 @@ const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('H
{post.content.length > 0 &&
diff --git a/src/lib/telegram/index.js b/src/lib/telegram/index.js
index 5327736..ffa98a3 100644
--- a/src/lib/telegram/index.js
+++ b/src/lib/telegram/index.js
@@ -13,6 +13,42 @@ const cache = new LRUCache({
},
})
+// 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
+}
+
+function getCustomEmojiImage(emojiId, staticProxy = '') {
+ if (!emojiId)
+ return null
+ const imageUrl = `https://t.me/i/emoji/${emojiId}.webp`
+ return `${staticProxy}${imageUrl}`
+}
+
+async function hydrateTgEmoji($, content, { staticProxy } = {}) {
+ const emojiNodes = $(content).find('tg-emoji')?.toArray() ?? []
+ if (!emojiNodes.length)
+ return
+
+ await Promise.all(emojiNodes.map((emojiEl) => {
+ const emojiId = $(emojiEl).attr('emoji-id')
+ if (!emojiId)
+ return
+
+ const imageUrl = getCustomEmojiImage(emojiId, staticProxy)
+ if (imageUrl) {
+ const imageMarkup = `

`
+ $(emojiEl).replaceWith(imageMarkup)
+ }
+ }))
+}
+
function getVideoStickers($, item, { staticProxy, index }) {
return $(item).find('.js-videosticker_video')?.map((_index, video) => {
const url = $(video)?.attr('src')
@@ -101,7 +137,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 +174,59 @@ function modifyHTMLContent($, content, { index } = {}) {
return content
}
-function getPost($, item, { channel, staticProxy, index = 0 }) {
+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 imageUrl = 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,
+ emojiId,
+ emojiImage,
+ count,
+ isPaid,
+ })
+ }
+ }
+
+ return reactions
+}
+
+async function getPost($, item, { channel, staticProxy, index = 0, reactionsEnabled } = {}) {
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 +264,7 @@ function getPost($, item, { channel, staticProxy, index = 0 }) {
}
return `${p1}${staticProxy}${p2}`
}),
+ reactions: reactionsEnabled ? getReactions($, item, staticProxy) : [],
}
}
@@ -197,6 +283,7 @@ export async function getChannelInfo(Astro, { before = '', after = '', q = '', t
const host = getEnv(import.meta.env, Astro, 'TELEGRAM_HOST') ?? 't.me'
const channel = getEnv(import.meta.env, Astro, 'CHANNEL')
const staticProxy = getEnv(import.meta.env, Astro, 'STATIC_PROXY') ?? '/static/'
+ const reactionsEnabled = getEnv(import.meta.env, Astro, 'REACTIONS')
const url = id ? `https://${host}/${channel}/${id}?embed=1&mode=tme` : `https://${host}/s/${channel}`
const headers = Object.fromEntries(Astro.request.headers)
@@ -221,19 +308,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, reactionsEnabled })
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, reactionsEnabled })
+ })?.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'),
}
diff --git a/src/pages/static/[...url].js b/src/pages/static/[...url].js
index 039e1f2..ea34f23 100644
--- a/src/pages/static/[...url].js
+++ b/src/pages/static/[...url].js
@@ -10,7 +10,9 @@ const targetWhitelist = [
export async function GET({ request, params, url }) {
try {
- const target = new URL(params.url + url.search)
+ const rawTarget = params.url + url.search
+ const normalizedTarget = rawTarget.startsWith('//') ? `https:${rawTarget}` : rawTarget
+ const target = new URL(normalizedTarget)
if (!targetWhitelist.some(domain => target.hostname.endsWith(domain))) {
return Response.redirect(target.toString(), 302)
}