mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 10:54:36 +03:00
feat: enhance message parsing with support for polls and replies, and improve HTML rendering
This commit is contained in:
+121
-2
@@ -230,6 +230,15 @@ func parsePublicMessages(body []byte) ([]protocol.Message, error) {
|
||||
mediaPrefix = protocol.MediaAudio
|
||||
case findFirstByClass(n, "tgme_widget_message_poll") != nil:
|
||||
mediaPrefix = protocol.MediaPoll
|
||||
pollBody := extractPollData(n)
|
||||
if pollBody != "" {
|
||||
if text != "" {
|
||||
text = mediaPrefix + "\n" + pollBody + "\n" + text
|
||||
} else {
|
||||
text = mediaPrefix + "\n" + pollBody
|
||||
}
|
||||
mediaPrefix = "" // already handled
|
||||
}
|
||||
case findFirstByClass(n, "tgme_widget_message_location_wrap") != nil ||
|
||||
findFirstByClass(n, "tgme_widget_message_venue_wrap") != nil:
|
||||
mediaPrefix = protocol.MediaLocation
|
||||
@@ -237,6 +246,11 @@ func parsePublicMessages(body []byte) ([]protocol.Message, error) {
|
||||
mediaPrefix = protocol.MediaContact
|
||||
case findFirstByClass(n, "tgme_widget_message_document_wrap") != nil:
|
||||
mediaPrefix = protocol.MediaFile
|
||||
case findFirstByClass(n, "message_media_not_supported") != nil:
|
||||
// Telegram shows "Please open Telegram to view this post" for
|
||||
// unsupported content like polls, quizzes, etc. Tag it so
|
||||
// the message is not silently dropped.
|
||||
mediaPrefix = protocol.MediaPoll
|
||||
}
|
||||
if mediaPrefix != "" {
|
||||
if text != "" {
|
||||
@@ -249,8 +263,13 @@ func parsePublicMessages(body []byte) ([]protocol.Message, error) {
|
||||
return
|
||||
}
|
||||
// Detect replies by checking for the reply preview element.
|
||||
if findFirstByClass(n, "tgme_widget_message_reply") != nil {
|
||||
text = protocol.MediaReply + "\n" + text
|
||||
if replyNode := findFirstByClass(n, "tgme_widget_message_reply"); replyNode != nil {
|
||||
replyID := extractReplyID(replyNode)
|
||||
if replyID > 0 {
|
||||
text = fmt.Sprintf("%s:%d\n%s", protocol.MediaReply, replyID, text)
|
||||
} else {
|
||||
text = protocol.MediaReply + "\n" + text
|
||||
}
|
||||
}
|
||||
collected = append(collected, publicMessage{
|
||||
id: id,
|
||||
@@ -421,6 +440,34 @@ func extractMessageText(n *html.Node) string {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
// Preserve hyperlinks: if <a href="URL"> and the link text differs from the URL, append it.
|
||||
if cur.Type == html.ElementNode && cur.Data == "a" {
|
||||
href := attrValue(cur, "href")
|
||||
// Only preserve safe http(s) URLs; reject javascript: and other schemes.
|
||||
if href != "" && !strings.HasPrefix(href, "http://") && !strings.HasPrefix(href, "https://") {
|
||||
href = ""
|
||||
}
|
||||
linkText := extractInnerText(cur)
|
||||
if href != "" && linkText != "" && linkText != href {
|
||||
if b.Len() > 0 {
|
||||
last := b.String()[b.Len()-1]
|
||||
if last != '\n' && last != ' ' {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString(linkText + " (" + href + ")")
|
||||
return // skip walking children, already consumed
|
||||
} else if href != "" && (linkText == "" || linkText == href) {
|
||||
if b.Len() > 0 {
|
||||
last := b.String()[b.Len()-1]
|
||||
if last != '\n' && last != ' ' {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString(href)
|
||||
return // skip walking children
|
||||
}
|
||||
}
|
||||
for child := cur.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
@@ -437,3 +484,75 @@ func trimTrailingSpace(b *strings.Builder) {
|
||||
b.Reset()
|
||||
b.WriteString(s)
|
||||
}
|
||||
|
||||
// extractInnerText returns the concatenated text content of a node and its children.
|
||||
func extractInnerText(n *html.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(cur *html.Node) {
|
||||
if cur.Type == html.TextNode {
|
||||
b.WriteString(cur.Data)
|
||||
}
|
||||
for child := cur.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// extractPollData extracts poll question and options from the public HTML widget.
|
||||
// Telegram's poll HTML uses these classes:
|
||||
// - tgme_widget_message_poll_question → question text
|
||||
// - tgme_widget_message_poll_option_text → each option's text
|
||||
func extractPollData(n *html.Node) string {
|
||||
question := ""
|
||||
if qNode := findFirstByClass(n, "tgme_widget_message_poll_question"); qNode != nil {
|
||||
question = strings.TrimSpace(extractMessageText(qNode))
|
||||
}
|
||||
var options []string
|
||||
visitNodes(n, func(cur *html.Node) {
|
||||
if hasClass(cur, "tgme_widget_message_poll_option_text") {
|
||||
opt := strings.TrimSpace(extractMessageText(cur))
|
||||
if opt != "" {
|
||||
options = append(options, "○ "+opt)
|
||||
}
|
||||
}
|
||||
})
|
||||
if question == "" && len(options) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := "📊 " + question
|
||||
if len(options) > 0 {
|
||||
result += "\n" + strings.Join(options, "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractReplyID parses the href of the reply element to get the replied-to message ID.
|
||||
// The href typically looks like "https://t.me/channel/123" or "?single&reply=123".
|
||||
func extractReplyID(replyNode *html.Node) uint32 {
|
||||
href := ""
|
||||
// The reply element itself may be an <a> or contain one.
|
||||
if replyNode.Type == html.ElementNode && replyNode.Data == "a" {
|
||||
href = attrValue(replyNode, "href")
|
||||
}
|
||||
if href == "" {
|
||||
linkNode := findFirstElement(replyNode, "a")
|
||||
if linkNode != nil {
|
||||
href = attrValue(linkNode, "href")
|
||||
}
|
||||
}
|
||||
if href == "" {
|
||||
return 0
|
||||
}
|
||||
// Parse the last path segment as the message ID.
|
||||
id, err := parsePostID(href)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -124,3 +127,153 @@ func TestParsePublicMessagesReplyPreviewUsesMainBody(t *testing.T) {
|
||||
t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, "[REPLY]\nthis is the real new post")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicMessagesReplyWithID(t *testing.T) {
|
||||
body := []byte(`
|
||||
<html><body>
|
||||
<div class="tgme_widget_message" data-post="testchan/305">
|
||||
<a class="tgme_widget_message_date"><time datetime="2026-04-10T12:00:00+00:00"></time></a>
|
||||
<a class="tgme_widget_message_reply" href="https://t.me/testchan/300">
|
||||
<div class="tgme_widget_message_text">original post</div>
|
||||
</a>
|
||||
<div class="tgme_widget_message_text">my reply text</div>
|
||||
</div>
|
||||
</body></html>
|
||||
`)
|
||||
|
||||
msgs, err := parsePublicMessages(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePublicMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
|
||||
}
|
||||
want := "[REPLY]:300\nmy reply text"
|
||||
if msgs[0].Text != want {
|
||||
t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicMessagesPoll(t *testing.T) {
|
||||
body := []byte(`
|
||||
<html><body>
|
||||
<div class="tgme_widget_message" data-post="testchan/400">
|
||||
<a class="tgme_widget_message_date"><time datetime="2026-04-10T12:00:00+00:00"></time></a>
|
||||
<div class="tgme_widget_message_poll">
|
||||
<div class="tgme_widget_message_poll_question">What is your favorite color?</div>
|
||||
<div class="tgme_widget_message_poll_option">
|
||||
<div class="tgme_widget_message_poll_option_text">Red</div>
|
||||
</div>
|
||||
<div class="tgme_widget_message_poll_option">
|
||||
<div class="tgme_widget_message_poll_option_text">Blue</div>
|
||||
</div>
|
||||
<div class="tgme_widget_message_poll_option">
|
||||
<div class="tgme_widget_message_poll_option_text">Green</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
`)
|
||||
|
||||
msgs, err := parsePublicMessages(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePublicMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
|
||||
}
|
||||
want := "[POLL]\n📊 What is your favorite color?\n○ Red\n○ Blue\n○ Green"
|
||||
if msgs[0].Text != want {
|
||||
t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageTextPreservesLinks(t *testing.T) {
|
||||
htmlStr := `<div class="tgme_widget_message_text">Check out <a href="https://example.com">this link</a> for details</div>`
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
t.Fatalf("html.Parse: %v", err)
|
||||
}
|
||||
node := findFirstByClass(doc, "tgme_widget_message_text")
|
||||
text := extractMessageText(node)
|
||||
want := "Check out this link (https://example.com) for details"
|
||||
if text != want {
|
||||
t.Fatalf("extractMessageText = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageTextBareURL(t *testing.T) {
|
||||
htmlStr := `<div class="tgme_widget_message_text">Visit <a href="https://example.com">https://example.com</a> now</div>`
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
t.Fatalf("html.Parse: %v", err)
|
||||
}
|
||||
node := findFirstByClass(doc, "tgme_widget_message_text")
|
||||
text := extractMessageText(node)
|
||||
want := "Visit https://example.com now"
|
||||
if text != want {
|
||||
t.Fatalf("extractMessageText = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageTextRejectsJavascriptURL(t *testing.T) {
|
||||
htmlStr := `<div class="tgme_widget_message_text"><a href="javascript:alert(1)">click me</a></div>`
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
t.Fatalf("html.Parse: %v", err)
|
||||
}
|
||||
node := findFirstByClass(doc, "tgme_widget_message_text")
|
||||
text := extractMessageText(node)
|
||||
// javascript: URLs should be stripped — only text remains
|
||||
want := "click me"
|
||||
if text != want {
|
||||
t.Fatalf("extractMessageText = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageTextRejectsDataURL(t *testing.T) {
|
||||
htmlStr := `<div class="tgme_widget_message_text"><a href="data:text/html,<script>alert(1)</script>">link</a></div>`
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
t.Fatalf("html.Parse: %v", err)
|
||||
}
|
||||
node := findFirstByClass(doc, "tgme_widget_message_text")
|
||||
text := extractMessageText(node)
|
||||
// data: URLs should be stripped — only text remains
|
||||
want := "link"
|
||||
if text != want {
|
||||
t.Fatalf("extractMessageText = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicMessagesUnsupportedMedia(t *testing.T) {
|
||||
// Real Telegram HTML for polls/quizzes in public view: no poll widget,
|
||||
// just a "message_media_not_supported" div.
|
||||
body := []byte(`
|
||||
<html><body>
|
||||
<div class="tgme_widget_message" data-post="testchan/181">
|
||||
<a class="tgme_widget_message_date"><time datetime="2026-05-01T10:00:00+00:00"></time></a>
|
||||
<div class="message_media_not_supported_wrap">
|
||||
<div class="message_media_not_supported">
|
||||
<div class="message_media_not_supported_label">Please open Telegram to view this post</div>
|
||||
<a href="https://t.me/testchan/181" class="message_media_view_in_telegram">VIEW IN TELEGRAM</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
`)
|
||||
|
||||
msgs, err := parsePublicMessages(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePublicMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
|
||||
}
|
||||
if msgs[0].ID != 181 {
|
||||
t.Fatalf("msgs[0].ID = %d, want 181", msgs[0].ID)
|
||||
}
|
||||
if msgs[0].Text != protocol.MediaPoll {
|
||||
t.Fatalf("msgs[0].Text = %q, want %q", msgs[0].Text, protocol.MediaPoll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,9 +398,17 @@ func (tr *TelegramReader) extractMessages(hist tg.MessagesMessagesClass, chatTyp
|
||||
}
|
||||
}
|
||||
|
||||
// Mark messages that are replies.
|
||||
if _, hasReply := msg.GetReplyTo(); hasReply {
|
||||
text = protocol.MediaReply + "\n" + text
|
||||
// Mark messages that are replies (include reply-to message ID).
|
||||
if replyTo, hasReply := msg.GetReplyTo(); hasReply {
|
||||
if rh, ok := replyTo.(*tg.MessageReplyHeader); ok {
|
||||
if rid, hasID := rh.GetReplyToMsgID(); hasID {
|
||||
text = fmt.Sprintf("%s:%d\n%s", protocol.MediaReply, rid, text)
|
||||
} else {
|
||||
text = protocol.MediaReply + "\n" + text
|
||||
}
|
||||
} else {
|
||||
text = protocol.MediaReply + "\n" + text
|
||||
}
|
||||
}
|
||||
|
||||
msgs = append(msgs, protocol.Message{
|
||||
@@ -429,6 +437,20 @@ func (tr *TelegramReader) extractText(msg *tg.Message) string {
|
||||
mediaPrefix = protocol.MediaContact
|
||||
case *tg.MessageMediaPoll:
|
||||
mediaPrefix = protocol.MediaPoll
|
||||
pollMedia := msg.Media.(*tg.MessageMediaPoll)
|
||||
question := pollMedia.Poll.Question.Text
|
||||
var opts []string
|
||||
for _, a := range pollMedia.Poll.Answers {
|
||||
opts = append(opts, "○ "+a.Text.Text)
|
||||
}
|
||||
pollBody := "📊 " + question
|
||||
if len(opts) > 0 {
|
||||
pollBody += "\n" + strings.Join(opts, "\n")
|
||||
}
|
||||
if text != "" {
|
||||
return mediaPrefix + "\n" + pollBody + "\n" + text
|
||||
}
|
||||
return mediaPrefix + "\n" + pollBody
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+184
-14
@@ -569,6 +569,51 @@
|
||||
color: #c084fc
|
||||
}
|
||||
|
||||
.reply-preview {
|
||||
background: rgba(192, 132, 252, .08);
|
||||
border-left: 3px solid #c084fc;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #aaa);
|
||||
white-space: pre-wrap;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.poll-card {
|
||||
background: rgba(51, 144, 236, .08);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px
|
||||
}
|
||||
|
||||
.poll-question {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.poll-option {
|
||||
padding: 6px 10px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255,255,255,.06);
|
||||
border-radius: 6px;
|
||||
font-size: 13px
|
||||
}
|
||||
|
||||
.msg a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all
|
||||
}
|
||||
|
||||
.msg a:hover {
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
/* ===== SEND PANEL ===== */
|
||||
.send-panel {
|
||||
display: none;
|
||||
@@ -1849,11 +1894,12 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||
<label data-i18n="scanner_targets">IPs or CIDRs (one per line)</label>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-flat" onclick="document.getElementById('scanTargets').value=''" data-i18n="scanner_clear_targets" style="font-size:12px;padding:4px 10px">🗑 Clear</button>
|
||||
<button class="btn btn-flat" onclick="document.getElementById('scanTargets').value='';updateScanIpCount()" data-i18n="scanner_clear_targets" style="font-size:12px;padding:4px 10px">🗑 Clear</button>
|
||||
<button class="btn btn-flat" onclick="loadScannerPresets()" style="font-size:12px;padding:4px 10px"><img class="iran-flag-icon" src="/static/iran-lion-sun.svg" alt="IR" style="height:14px;vertical-align:middle;margin-right:2px"> <span data-i18n="scanner_load_presets">Load IR Presets</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="scanTargets" rows="3" placeholder="5.1.0.0/16 8.8.8.8 1.1.1.1" style="width:100%;font-family:monospace;font-size:13px"></textarea>
|
||||
<textarea id="scanTargets" rows="3" placeholder="5.1.0.0/16 8.8.8.8 1.1.1.1" style="width:100%;font-family:monospace;font-size:13px" oninput="updateScanIpCount()"></textarea>
|
||||
<div id="scanIpCount" style="margin-top:4px;font-size:12px;color:var(--text-dim);display:none"></div>
|
||||
<div id="scanPresetTag" style="display:none;margin-top:6px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;font-size:13px;color:var(--text)"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -2030,6 +2076,8 @@
|
||||
scanner_read_less: 'بستن',
|
||||
scanner_load_presets: 'بارگذاری لیست ایران',
|
||||
scanner_preset_active: 'ریزالورهای ایران بارگذاری شد',
|
||||
scanner_from_input: 'از ورودی',
|
||||
scanner_from_preset: 'از پیشفرض',
|
||||
scanner_new_scan: 'اسکن جدید',
|
||||
scanner_advanced: 'تنظیمات پیشرفته',
|
||||
scanner_copy_all: 'کپی همه',
|
||||
@@ -2099,6 +2147,7 @@
|
||||
password_mismatch: 'رمزها مطابقت ندارند',
|
||||
password_wrong: 'رمز عبور اشتباه است',
|
||||
password_empty: 'رمز عبور نمی\u200cتواند خالی باشد',
|
||||
poll_placeholder: 'نظرسنجی (برای مشاهده تلگرام را باز کنید)',
|
||||
},
|
||||
en: {
|
||||
search: 'Search...', settings: 'Settings', profiles: 'Profiles',
|
||||
@@ -2178,6 +2227,8 @@
|
||||
scanner_read_less: 'Show less',
|
||||
scanner_load_presets: 'Load IR Presets',
|
||||
scanner_preset_active: 'Iran resolvers loaded',
|
||||
scanner_from_input: 'from input',
|
||||
scanner_from_preset: 'from preset',
|
||||
scanner_new_scan: 'New Scan',
|
||||
scanner_advanced: 'Advanced options',
|
||||
scanner_copy_all: 'Copy All',
|
||||
@@ -2247,6 +2298,7 @@
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_wrong: 'Wrong password',
|
||||
password_empty: 'Password cannot be empty',
|
||||
poll_placeholder: 'Poll (open Telegram to view)',
|
||||
}
|
||||
};
|
||||
var lang = localStorage.getItem('thefeed_lang') || 'fa';
|
||||
@@ -2746,7 +2798,7 @@
|
||||
var h = '<div style="margin-bottom:4px"><label style="font-size:11px;color:var(--text-dim)">' + t('select_resolvers_export') + '</label></div>';
|
||||
h += '<div style="display:flex;gap:4px;margin-bottom:4px"><button class="btn btn-flat" style="font-size:10px;padding:2px 6px" onclick="toggleAllShareResolvers(\'' + id + '\',true)">' + t('select_all') + '</button><button class="btn btn-flat" style="font-size:10px;padding:2px 6px" onclick="toggleAllShareResolvers(\'' + id + '\',false)">' + t('select_none') + '</button></div>';
|
||||
for (var i = 0; i < bank.length; i++) {
|
||||
h += '<label style="display:block;font-size:11px;font-family:monospace;cursor:pointer"><input type="checkbox" class="share-r-cb" data-profile="' + id + '" value="' + esc(bank[i].addr) + '" checked onchange="updateShareUri(\'' + id + '\')" style="width:auto;margin-inline-end:4px">' + esc(bank[i].addr) + '</label>';
|
||||
h += '<label style="display:block;font-size:11px;font-family:monospace;cursor:pointer"><input type="checkbox" class="share-r-cb" data-profile="' + id + '" value="' + escAttr(bank[i].addr) + '" checked onchange="updateShareUri(\'' + id + '\')" style="width:auto;margin-inline-end:4px">' + esc(bank[i].addr) + '</label>';
|
||||
}
|
||||
resolverEl.innerHTML = h;
|
||||
}
|
||||
@@ -2905,7 +2957,7 @@
|
||||
var h = '';
|
||||
for (var i = 0; i < chans.length; i++) {
|
||||
h += '<div class="channel-list-item"><span>' + esc(chans[i]) + '</span>';
|
||||
h += '<button class="btn btn-flat btn-sm" style="color:var(--error)" data-ch="' + esc(chans[i]) + '" onclick="removeChannelEditor(this.dataset.ch)">' + t('remove') + '</button></div>';
|
||||
h += '<button class="btn btn-flat btn-sm" style="color:var(--error)" data-ch="' + escAttr(chans[i]) + '" onclick="removeChannelEditor(this.dataset.ch)">' + t('remove') + '</button></div>';
|
||||
}
|
||||
el.innerHTML = h;
|
||||
} catch (e) { el.innerHTML = '<div style="color:var(--error);font-size:12px">' + esc(e.message) + '</div>' }
|
||||
@@ -3066,7 +3118,7 @@
|
||||
var lastID = e.ch.LastMsgID || e.ch.lastMsgID || 0;
|
||||
var chNm2 = e.ch.Name || e.ch.name || '';
|
||||
var badge = (previousMsgIDs[chNm2] > 0 && lastID > previousMsgIDs[chNm2] && num2 !== selectedChannel) ? '<span class="ch-badge">NEW</span>' : '';
|
||||
h += '<div class="ch-item' + active + '" data-name="' + esc(name) + '" onclick="selectChannel(' + num2 + ')">';
|
||||
h += '<div class="ch-item' + active + '" data-name="' + escAttr(name) + '" onclick="selectChannel(' + num2 + ')">';
|
||||
h += '<div class="ch-avatar">' + esc(avatarText) + '</div>';
|
||||
h += '<div class="ch-info"><div class="ch-name">' + esc(name) + (isPriv ? '<span class="ch-type-tag">' + t('private') + '</span>' : (isX ? '<span class="ch-type-tag x-tag">' + t('x_label') + '</span>' : '')) + '</div>';
|
||||
h += '<div class="ch-preview">' + badge + '</div></div></div>';
|
||||
@@ -3139,6 +3191,28 @@
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
function renderPollCard(pollBody) {
|
||||
var lines = pollBody.split('\n');
|
||||
var html = '<div class="poll-card">';
|
||||
var hasContent = false;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var ln = lines[i];
|
||||
if (ln.indexOf('📊 ') === 0) {
|
||||
html += '<div class="poll-question">' + esc(ln.substring(2).trim()) + '</div>';
|
||||
hasContent = true;
|
||||
} else if (ln.indexOf('○ ') === 0) {
|
||||
html += '<div class="poll-option">' + esc(ln) + '</div>';
|
||||
hasContent = true;
|
||||
} else if (ln.trim()) {
|
||||
html += '<div>' + linkify(esc(ln)) + '</div>';
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
if (!hasContent) html += '<div class="poll-question" style="opacity:.5">' + t('poll_placeholder') + '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMessages(msgs, gaps) {
|
||||
var el = document.getElementById('messages');
|
||||
if (!msgs || !msgs.length) { el.innerHTML = '<div class="empty-state"><p>' + t('no_messages') + '</p><p style="font-size:12px;opacity:.6;margin-top:6px">' + t('no_messages_hint') + '</p></div>'; return }
|
||||
@@ -3150,6 +3224,9 @@
|
||||
// Build a lookup: message ID → gap count to insert BEFORE that message.
|
||||
var gapBefore = {};
|
||||
if (gaps) { for (var g = 0; g < gaps.length; g++) { gapBefore[gaps[g].before_id] = gaps[g].count } }
|
||||
// Build a lookup: message ID → message object (for reply previews).
|
||||
var msgByID = {};
|
||||
for (var mi = 0; mi < msgs.length; mi++) { var mid = msgs[mi].ID || msgs[mi].id; if (mid) msgByID[mid] = msgs[mi] }
|
||||
currentMsgTexts = [];
|
||||
var dateLocale = lang === 'fa' ? 'fa-IR' : 'en-US';
|
||||
var dateOpts = lang === 'fa' ? { year: 'numeric', month: 'long', day: 'numeric', calendar: 'persian' } : { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
@@ -3178,13 +3255,40 @@
|
||||
var timeStr = ts.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit' });
|
||||
var text = msg.Text || msg.text || '';
|
||||
currentMsgTexts.push(text);
|
||||
var mediaHtml = '', textHtml = esc(text).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}\u2600\uFE0F" style="height:1.1em;vertical-align:middle">');
|
||||
var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[POLL]', '[CONTACT]', '[LOCATION]', '[REPLY]'];
|
||||
for (var m = 0; m < mediaTypes.length; m++) {
|
||||
if (text.indexOf(mediaTypes[m]) === 0) {
|
||||
var tagCls = mediaTypes[m] === '[REPLY]' ? 'media-tag reply-tag' : 'media-tag';
|
||||
mediaHtml = '<div class="' + tagCls + '">' + mediaTypes[m] + '</div>';
|
||||
textHtml = esc(text.substring(mediaTypes[m].length).replace(/^\n/, '')); break
|
||||
var mediaHtml = '', textHtml = linkify(esc(text)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}\u2600\uFE0F" style="height:1.1em;vertical-align:middle">');
|
||||
// Check for [REPLY]:ID or [REPLY] format (backward compat: also [REPLY:ID])
|
||||
var replyMatch = text.match(/^\[REPLY\](?::(\d+))?/) || text.match(/^\[REPLY:(\d+)\]/);
|
||||
if (replyMatch) {
|
||||
var replyTag = replyMatch[0];
|
||||
var replyId = replyMatch[1] ? parseInt(replyMatch[1]) : 0;
|
||||
mediaHtml = '<div class="media-tag reply-tag">[REPLY]</div>';
|
||||
var replyBody = text.substring(replyTag.length).replace(/^\n/, '');
|
||||
// If reply body contains a poll, render it as a styled poll card
|
||||
if (replyBody.indexOf('[POLL]') === 0) {
|
||||
var rpPollBody = replyBody.substring('[POLL]'.length).replace(/^\n/, '');
|
||||
textHtml = renderPollCard(rpPollBody);
|
||||
mediaHtml += '<div class="media-tag">[POLL]</div>';
|
||||
} else {
|
||||
textHtml = linkify(esc(replyBody)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}\u2600\uFE0F" style="height:1.1em;vertical-align:middle">');
|
||||
}
|
||||
if (replyId > 0 && msgByID[replyId]) {
|
||||
var rpText = (msgByID[replyId].Text || msgByID[replyId].text || '').replace(/^\[(?:IMAGE|VIDEO|FILE|AUDIO|STICKER|GIF|POLL|CONTACT|LOCATION|REPLY)[^\]]*\](?::\d+)?\n?/, '');
|
||||
if (rpText.length > 120) rpText = rpText.substring(0, 120) + '…';
|
||||
mediaHtml += '<div class="reply-preview" onclick="scrollToMsg(' + replyId + ')" title="#' + replyId + '">' + esc(rpText) + '</div>';
|
||||
}
|
||||
} else if (text.indexOf('[POLL]') === 0) {
|
||||
// Render poll with styled card
|
||||
mediaHtml = '<div class="media-tag">[POLL]</div>';
|
||||
var pollBody = text.substring('[POLL]'.length).replace(/^\n/, '');
|
||||
textHtml = renderPollCard(pollBody);
|
||||
} else {
|
||||
// Other media types
|
||||
var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[CONTACT]', '[LOCATION]'];
|
||||
for (var m = 0; m < mediaTypes.length; m++) {
|
||||
if (text.indexOf(mediaTypes[m]) === 0) {
|
||||
mediaHtml = '<div class="media-tag">' + mediaTypes[m] + '</div>';
|
||||
textHtml = linkify(esc(text.substring(mediaTypes[m].length).replace(/^\n/, ''))).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}\u2600\uFE0F" style="height:1.1em;vertical-align:middle">'); break
|
||||
}
|
||||
}
|
||||
}
|
||||
html += '<div class="msg' + (isPersian(text) ? ' rtl-msg' : '') + '" dir="auto">' + mediaHtml + textHtml + '<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg(' + i + ')">' + t('copy') + '</button><span>#' + id + '</span><span>' + timeStr + '</span></div></div>';
|
||||
@@ -3503,12 +3607,74 @@
|
||||
|
||||
// ===== UTILITIES =====
|
||||
function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, ''') }
|
||||
function linkify(s) {
|
||||
return s.replace(/(https?:\/\/[^\s<>&"']+)/g, function(url) {
|
||||
var trail = '';
|
||||
// Strip trailing punctuation that's not part of the URL
|
||||
while (url.length > 1) {
|
||||
var ch = url[url.length - 1];
|
||||
if (ch === ')' && url.split('(').length <= url.split(')').length - 1) {
|
||||
trail = ch + trail; url = url.slice(0, -1);
|
||||
} else if (/[.,;:!?>\u200C\u200F]/.test(ch)) {
|
||||
trail = ch + trail; url = url.slice(0, -1);
|
||||
} else { break }
|
||||
}
|
||||
return '<a href="' + escAttr(url) + '" target="_blank" rel="noopener" dir="ltr">' + url + '</a>' + trail;
|
||||
});
|
||||
}
|
||||
function scrollToMsg(id) {
|
||||
var els = document.querySelectorAll('.msg');
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var meta = els[i].querySelector('.msg-meta');
|
||||
if (meta && meta.textContent.indexOf('#' + id) !== -1) {
|
||||
els[i].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
els[i].style.outline = '2px solid var(--accent)';
|
||||
setTimeout(function(el) { el.style.outline = '' }, 1500, els[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
function isPersian(text) { return text && (text.match(/[\u0600-\u06FF]/g) || []).length > text.length * 0.25 }
|
||||
|
||||
// ===== SCANNER =====
|
||||
var scanPollTimer = null;
|
||||
var scanLastResults = []; // cache for selection
|
||||
var scannerActivePreset = ''; // server-side preset name (e.g. 'ir')
|
||||
var scannerPresetIpCount = 0; // IP count from preset
|
||||
|
||||
function countCIDRIPs(text) {
|
||||
var lines = text.split('\n');
|
||||
var total = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line || line[0] === '#') continue;
|
||||
var slash = line.indexOf('/');
|
||||
if (slash !== -1) {
|
||||
var prefix = parseInt(line.substring(slash + 1));
|
||||
if (prefix >= 0 && prefix <= 32) total += (1 << (32 - prefix)) >>> 0;
|
||||
} else if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(line)) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function updateScanIpCount() {
|
||||
var el = document.getElementById('scanIpCount');
|
||||
var text = document.getElementById('scanTargets').value;
|
||||
var count = countCIDRIPs(text);
|
||||
var presetCount = scannerActivePreset ? scannerPresetIpCount : 0;
|
||||
var totalCount = count + presetCount;
|
||||
if (totalCount > 0) {
|
||||
var parts = [];
|
||||
if (count > 0) parts.push(count.toLocaleString() + ' ' + t('scanner_from_input'));
|
||||
if (presetCount > 0) parts.push(presetCount.toLocaleString() + ' ' + t('scanner_from_preset'));
|
||||
el.textContent = '≈ ' + totalCount.toLocaleString() + ' IPs' + (parts.length > 1 ? ' (' + parts.join(' + ') + ')' : '');
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function openScanner() {
|
||||
document.getElementById('scannerModal').classList.add('active');
|
||||
@@ -3539,7 +3705,9 @@
|
||||
if (scannerActivePreset === 'ir') {
|
||||
// Toggle off
|
||||
scannerActivePreset = '';
|
||||
scannerPresetIpCount = 0;
|
||||
renderPresetTag();
|
||||
updateScanIpCount();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -3549,7 +3717,9 @@
|
||||
var presets = data.presets || [];
|
||||
if (presets.length > 0) {
|
||||
scannerActivePreset = presets[0].name;
|
||||
scannerPresetIpCount = presets[0].count || 0;
|
||||
renderPresetTag();
|
||||
updateScanIpCount();
|
||||
}
|
||||
} catch (e) { showToast(e.message) }
|
||||
}
|
||||
@@ -3700,10 +3870,10 @@
|
||||
var r = results[i];
|
||||
var tr = document.createElement('tr');
|
||||
tr.style.borderTop = '1px solid var(--border)';
|
||||
tr.innerHTML = '<td style="padding:8px"><input type="checkbox" class="scan-select-cb" data-ip="' + esc(r.ip) + '" checked onchange="updateScanSelectedCount()"></td>' +
|
||||
tr.innerHTML = '<td style="padding:8px"><input type="checkbox" class="scan-select-cb" data-ip="' + escAttr(r.ip) + '" checked onchange="updateScanSelectedCount()"></td>' +
|
||||
'<td style="padding:8px;font-family:monospace;font-size:13px">' + esc(r.ip) + '</td>' +
|
||||
'<td style="padding:8px;text-align:right;font-size:13px;color:var(--text-dim)">' + (r.latencyMs != null ? Math.round(r.latencyMs) + 'ms' : '-') + '</td>' +
|
||||
'<td style="padding:4px 8px"><button class="btn btn-flat" style="font-size:12px;padding:4px 8px;min-width:0" onclick="navigator.clipboard.writeText(\'' + esc(r.ip) + '\');showToast(t(\'copied\'))">☰</button></td>';
|
||||
'<td style="padding:4px 8px"><button class="btn btn-flat" style="font-size:12px;padding:4px 8px;min-width:0" onclick="navigator.clipboard.writeText(\'' + escAttr(r.ip) + '\');showToast(t(\'copied\'))">☰</button></td>';
|
||||
body.appendChild(tr);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user