mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 07:14:35 +03:00
8518 lines
350 KiB
HTML
8518 lines
350 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fa" dir="rtl">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<meta name="theme-color" content="#17212b">
|
||
<meta name="theme-color" content="#17212b" media="(prefers-color-scheme: dark)">
|
||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||
<title>thefeed</title>
|
||
<script>
|
||
if (typeof Android !== 'undefined') document.documentElement.classList.add('is-android-app');
|
||
</script>
|
||
<style>
|
||
@font-face {
|
||
font-family: 'Vazirmatn';
|
||
src: url('/static/Vazirmatn-Regular.woff2') format('woff2');
|
||
font-weight: normal;
|
||
font-style: normal;
|
||
font-display: swap
|
||
}
|
||
|
||
:root {
|
||
--bg: #17212b;
|
||
--bg2: #0e1621;
|
||
--sidebar-bg: #0e1621;
|
||
--surface: #182533;
|
||
--surface2: #1d2b3a;
|
||
--border: #1c2938;
|
||
--accent: #3390ec;
|
||
--accent-hover: #2b7dd6;
|
||
--text: #f5f5f5;
|
||
--text-dim: #8b9eb0;
|
||
--success: #4fae4e;
|
||
--error: #e53935;
|
||
--send-color: #3390ec;
|
||
--msg-in: #182533;
|
||
--msg-out: #2b5278;
|
||
--hover: #1e2c3a;
|
||
--font-size: 14px;
|
||
--safe-top: env(safe-area-inset-top);
|
||
--safe-right: env(safe-area-inset-right);
|
||
--safe-bottom: env(safe-area-inset-bottom);
|
||
--safe-left: env(safe-area-inset-left);
|
||
}
|
||
/* Android WebView lays the page out below the system bars and reports
|
||
phantom safe-area insets — zero them out so we don't double-pad. */
|
||
html.is-android-app {
|
||
--safe-top: 0px;
|
||
--safe-right: 0px;
|
||
--safe-bottom: 0px;
|
||
--safe-left: 0px;
|
||
}
|
||
|
||
:root[data-theme="light"] {
|
||
--bg: #ffffff;
|
||
--bg2: #f0f2f5;
|
||
--sidebar-bg: #f0f2f5;
|
||
--surface: #ffffff;
|
||
--surface2: #e8eaed;
|
||
--border: #d3d6da;
|
||
--accent: #3390ec;
|
||
--accent-hover: #2b7dd6;
|
||
--text: #1a1a1a;
|
||
--text-dim: #707579;
|
||
--success: #4fae4e;
|
||
--error: #e53935;
|
||
--send-color: #3390ec;
|
||
--msg-in: #ffffff;
|
||
--msg-out: #d3eafe;
|
||
--hover: #e8eaed;
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box
|
||
}
|
||
|
||
body {
|
||
font-family: 'Vazirmatn', system-ui, -apple-system, sans-serif;
|
||
background: var(--bg2);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
overflow: hidden;
|
||
font-size: var(--font-size);
|
||
padding-top: var(--safe-top);
|
||
padding-bottom: var(--safe-bottom);
|
||
padding-left: var(--safe-left);
|
||
padding-right: var(--safe-right)
|
||
}
|
||
|
||
button {
|
||
cursor: pointer;
|
||
font-family: inherit
|
||
}
|
||
|
||
input,
|
||
textarea,
|
||
select {
|
||
font-family: inherit
|
||
}
|
||
|
||
/* ===== LAYOUT ===== */
|
||
.app {
|
||
display: flex;
|
||
height: 100%;
|
||
direction: ltr
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
min-width: 280px;
|
||
background: var(--sidebar-bg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid var(--border);
|
||
overflow: hidden
|
||
}
|
||
|
||
.chat-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: var(--bg);
|
||
overflow: hidden;
|
||
position: relative
|
||
}
|
||
|
||
/* ===== SIDEBAR HEADER ===== */
|
||
.sidebar-header {
|
||
padding: 10px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
border-bottom: 1px solid var(--border)
|
||
}
|
||
|
||
.sidebar-header-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 40px
|
||
}
|
||
|
||
.profile-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 12px;
|
||
border: none;
|
||
border-radius: 20px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
text-align: left;
|
||
transition: background .15s;
|
||
overflow: hidden
|
||
}
|
||
|
||
.profile-btn:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.profile-btn-avatar {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.profile-btn-name {
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.profile-btn-arrow {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px
|
||
}
|
||
|
||
.profile-btn-arrow .plus {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--accent)
|
||
}
|
||
|
||
.sidebar-search {
|
||
padding: 7px 12px;
|
||
border: none;
|
||
border-radius: 18px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
outline: none;
|
||
width: 100%
|
||
}
|
||
|
||
.sidebar-search::placeholder {
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-dim);
|
||
font-size: 17px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
background: var(--hover);
|
||
color: var(--text)
|
||
}
|
||
|
||
.icon-btn.scanning {
|
||
animation: spin 1.2s linear infinite;
|
||
color: var(--accent)
|
||
}
|
||
|
||
/* Sidebar toolbar: compact text buttons */
|
||
.sidebar-toolbar {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 4px 12px 6px;
|
||
flex-wrap: wrap
|
||
}
|
||
.sidebar-toolbar .stb {
|
||
flex: 1 1 auto;
|
||
padding: 5px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--surface);
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
transition: background .15s, color .15s
|
||
}
|
||
.sidebar-toolbar .stb:hover {
|
||
background: var(--hover);
|
||
color: var(--text)
|
||
}
|
||
.sidebar-toolbar .stb.scanning {
|
||
color: var(--accent);
|
||
border-color: var(--accent)
|
||
}
|
||
.sidebar-toolbar .stb .stb-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
margin-left: 3px;
|
||
vertical-align: middle
|
||
}
|
||
|
||
/* ===== CHANNEL LIST ===== */
|
||
.channel-list {
|
||
flex: 1;
|
||
overflow-y: auto
|
||
}
|
||
|
||
.channel-section-title {
|
||
padding: 8px 14px 4px;
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px
|
||
}
|
||
|
||
.ch-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px 14px;
|
||
cursor: pointer;
|
||
gap: 10px;
|
||
contain: content
|
||
}
|
||
|
||
.ch-item:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.ch-item.active {
|
||
background: var(--accent)
|
||
}
|
||
|
||
.ch-item.active .ch-name,
|
||
.ch-item.active .ch-preview {
|
||
color: #fff
|
||
}
|
||
|
||
.ch-avatar {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: var(--surface2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 17px;
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
font-weight: 600
|
||
}
|
||
|
||
.ch-item.active .ch-avatar {
|
||
background: rgba(255, 255, 255, .2)
|
||
}
|
||
|
||
.ch-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px
|
||
}
|
||
|
||
.ch-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.ch-preview {
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.ch-sub {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
opacity: .7
|
||
}
|
||
|
||
.ch-item.active .ch-sub {
|
||
color: rgba(255,255,255,.75)
|
||
}
|
||
|
||
/* Auto-update toggle on each channel row. */
|
||
.ch-autoupdate {
|
||
flex: 0 0 auto;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text-dim);
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
transition: background .15s, color .15s, border-color .15s;
|
||
opacity: .55;
|
||
}
|
||
.ch-item:hover .ch-autoupdate {
|
||
opacity: 1;
|
||
}
|
||
.ch-autoupdate.on {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
opacity: 1;
|
||
}
|
||
.ch-item.active .ch-autoupdate {
|
||
color: rgba(255, 255, 255, .9);
|
||
border-color: rgba(255, 255, 255, .35);
|
||
}
|
||
.ch-item.active .ch-autoupdate.on {
|
||
background: #fff;
|
||
color: var(--accent);
|
||
border-color: #fff;
|
||
}
|
||
|
||
.ch-badge {
|
||
background: #fff;
|
||
color: var(--accent);
|
||
font-size: 10px;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
min-width: 18px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
font-weight: 700
|
||
}
|
||
|
||
.ch-item.active .ch-badge {
|
||
background: rgba(255, 255, 255, .3);
|
||
color: #fff
|
||
}
|
||
|
||
.ch-type-tag {
|
||
font-size: 9px;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
background: rgba(192, 132, 252, .15);
|
||
color: #c084fc;
|
||
margin-left: 4px
|
||
}
|
||
|
||
.ch-type-tag.x-tag {
|
||
background: rgba(29, 161, 242, .2);
|
||
color: #72c9ff
|
||
}
|
||
|
||
/* ===== RTL MESSAGES ===== */
|
||
.msg.rtl-msg {
|
||
direction: rtl
|
||
}
|
||
|
||
/* ===== SIDEBAR FOOTER ===== */
|
||
.sidebar-footer {
|
||
padding: 8px 14px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
font-size: 10px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.sidebar-footer a {
|
||
color: var(--accent);
|
||
text-decoration: none
|
||
}
|
||
|
||
.sidebar-footer a:hover {
|
||
text-decoration: underline
|
||
}
|
||
|
||
.free-iran {
|
||
color: var(--success);
|
||
font-weight: 700
|
||
}
|
||
|
||
/* ===== CHAT HEADER ===== */
|
||
.chat-header {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 14px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
min-height: 54px;
|
||
gap: 10px
|
||
}
|
||
|
||
.back-btn {
|
||
display: none;
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text);
|
||
font-size: 20px;
|
||
border-radius: 50%;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.back-btn:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.chat-header-info {
|
||
flex: 1;
|
||
min-width: 0
|
||
}
|
||
|
||
.chat-header-name {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.chat-header-sub {
|
||
font-size: 11px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.chat-header-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
align-items: center
|
||
}
|
||
|
||
.next-fetch-label {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
padding: 0 2px
|
||
}
|
||
|
||
.next-fetch-info {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
cursor: help;
|
||
line-height: 1
|
||
}
|
||
|
||
/* ===== SEARCH BAR ===== */
|
||
.msg-search-bar {
|
||
display: none;
|
||
padding: 6px 14px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 8px;
|
||
align-items: center
|
||
}
|
||
.msg-search-bar.active { display: flex }
|
||
.msg-search-bar input {
|
||
flex: 1;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
outline: none
|
||
}
|
||
.msg-search-bar input:focus { border-color: var(--accent) }
|
||
.msg-search-bar .search-nav { display: flex; gap: 4px; align-items: center; font-size: 12px; color: var(--text-dim) }
|
||
.msg-search-bar button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 4px }
|
||
.msg-search-bar button:hover { color: var(--text) }
|
||
.msg .search-highlight { background: rgba(255,200,0,.35); border-radius: 2px; padding: 0 1px }
|
||
.msg .search-highlight.current { background: rgba(255,200,0,.7) }
|
||
|
||
/* ===== EXPORT MODAL ===== */
|
||
.export-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px }
|
||
.export-row input[type=number] { width: 80px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 13px }
|
||
|
||
/* ===== MESSAGES ===== */
|
||
.messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
direction: ltr
|
||
}
|
||
|
||
.msg-date-sep {
|
||
text-align: center;
|
||
padding: 8px 0;
|
||
font-size: 12px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.msg-date-sep span {
|
||
background: rgba(0, 0, 0, .3);
|
||
padding: 3px 10px;
|
||
border-radius: 10px
|
||
}
|
||
|
||
.msg-gap-sep {
|
||
text-align: center;
|
||
padding: 6px 0;
|
||
font-size: 11px;
|
||
color: var(--error)
|
||
}
|
||
|
||
.msg-gap-sep span {
|
||
background: rgba(229, 57, 53, .12);
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
border: 1px dashed rgba(229, 57, 53, .3)
|
||
}
|
||
|
||
.msg {
|
||
max-width: min(82%, 580px);
|
||
padding: 7px 10px 4px;
|
||
border-radius: 12px;
|
||
line-height: 1.7;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
font-size: inherit;
|
||
background: var(--msg-in);
|
||
border: 1px solid rgba(255, 255, 255, .07);
|
||
align-self: flex-start;
|
||
border-bottom-left-radius: 4px
|
||
}
|
||
|
||
/* Telegram-like: when a post carries a media card, the bubble shrinks
|
||
to track the media's width so the caption wraps to match. The media
|
||
fills the bubble's content area edge-to-edge (within padding). */
|
||
.msg.has-media {
|
||
max-width: min(95%, 402px)
|
||
}
|
||
|
||
/* Outgoing private-chat messages — right-aligned bubble with
|
||
its own colour and the [YOU] label inside, mirroring the
|
||
chat-app convention so the user can tell their replies apart
|
||
from the contact's at a glance. */
|
||
.msg.msg-outgoing {
|
||
align-self: flex-end;
|
||
background: var(--msg-out);
|
||
border-bottom-left-radius: 12px;
|
||
border-bottom-right-radius: 4px
|
||
}
|
||
|
||
.you-tag {
|
||
display: inline-block;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
padding: 1px 7px;
|
||
margin-bottom: 4px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
color: #fff
|
||
}
|
||
|
||
:root[data-theme="light"] .you-tag {
|
||
background: rgba(0, 0, 0, 0.08);
|
||
color: var(--text)
|
||
}
|
||
|
||
.msg.has-media .media-card,
|
||
.msg.has-media .media-album {
|
||
max-width: 100%
|
||
}
|
||
|
||
:root[data-theme="light"] .msg {
|
||
border-color: var(--border)
|
||
}
|
||
|
||
.msg-meta {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 6px;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-top: 2px;
|
||
direction: ltr
|
||
}
|
||
|
||
.media-tag {
|
||
display: block;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
background: rgba(51, 144, 236, .15);
|
||
color: var(--accent);
|
||
font-size: 11px;
|
||
margin-bottom: 6px
|
||
}
|
||
|
||
.media-tag.reply-tag {
|
||
background: rgba(192, 132, 252, .15);
|
||
color: #c084fc
|
||
}
|
||
|
||
/* ===== DOWNLOADABLE MEDIA CARDS ===== */
|
||
.media-card {
|
||
margin-bottom: 6px;
|
||
border-radius: 10px;
|
||
overflow: hidden
|
||
}
|
||
|
||
.media-album {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-bottom: 6px
|
||
}
|
||
|
||
.media-album .media-card {
|
||
margin-bottom: 0
|
||
}
|
||
|
||
.media-image {
|
||
background: var(--surface2);
|
||
max-width: 380px
|
||
}
|
||
|
||
.media-image-preview {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 120px
|
||
}
|
||
|
||
.media-image-loaded {
|
||
width: 100%;
|
||
max-height: 480px;
|
||
object-fit: contain;
|
||
display: block;
|
||
cursor: zoom-in
|
||
}
|
||
|
||
/* Action row that overlays the bottom of a loaded image card. Open,
|
||
Share, Save buttons live here. */
|
||
.media-image-actions {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 8px;
|
||
display: flex;
|
||
gap: 6px
|
||
}
|
||
|
||
.media-action-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, .55);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background .15s
|
||
}
|
||
|
||
.media-action-icon:hover {
|
||
background: rgba(0, 0, 0, .8)
|
||
}
|
||
|
||
/* In-progress state — replaces the action chip in the centre of the
|
||
image preview with a thicker, clearly-visible progress bar plus a
|
||
circular cancel button. */
|
||
.media-progress-overlay {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 6px;
|
||
width: 80%;
|
||
max-width: 280px;
|
||
padding: 12px 14px;
|
||
background: rgba(0, 0, 0, .6);
|
||
border-radius: 12px;
|
||
color: #fff;
|
||
position: relative
|
||
}
|
||
|
||
.media-progress-overlay .media-cancel-btn {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 24px;
|
||
height: 24px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, .15);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1
|
||
}
|
||
|
||
.media-progress-overlay .media-cancel-btn:hover {
|
||
background: rgba(255, 255, 255, .3)
|
||
}
|
||
|
||
.media-progress-overlay .media-progress-bar {
|
||
height: 6px;
|
||
background: rgba(255, 255, 255, .25);
|
||
overflow: hidden;
|
||
position: relative
|
||
}
|
||
|
||
.media-progress-overlay .media-progress-fill {
|
||
background: var(--accent);
|
||
transition: width .25s ease-out
|
||
}
|
||
|
||
/* Active-download indicator: a moving diagonal stripe pattern overlaid on
|
||
the bar. Always animates while .downloading is on the card, so the user
|
||
sees activity even before the first onprogress event arrives. */
|
||
.media-progress-overlay .media-progress-bar,
|
||
.media-card.downloading .media-progress-bar {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background-image: linear-gradient(
|
||
45deg,
|
||
rgba(0, 0, 0, .12) 25%, transparent 25%,
|
||
transparent 50%, rgba(0, 0, 0, .12) 50%,
|
||
rgba(0, 0, 0, .12) 75%, transparent 75%, transparent
|
||
);
|
||
background-size: 16px 16px;
|
||
animation: media-prog-stripes 0.8s linear infinite;
|
||
}
|
||
.media-card.downloading .media-progress-bar-thin {
|
||
height: 8px;
|
||
margin-top: 6px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
@keyframes media-prog-stripes {
|
||
0% { background-position: 0 0 }
|
||
100% { background-position: 16px 0 }
|
||
}
|
||
|
||
.media-file-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center
|
||
}
|
||
|
||
.media-file-btn-cancel {
|
||
background: var(--error)
|
||
}
|
||
|
||
.media-file-btn-cancel:hover {
|
||
background: var(--error)
|
||
}
|
||
|
||
.media-action-queued {
|
||
background: rgba(0, 0, 0, .4)
|
||
}
|
||
|
||
.media-file-btn-queued {
|
||
background: var(--text-dim)
|
||
}
|
||
|
||
/* Full-screen lightbox for "Open image". Works the same in browsers and
|
||
Android WebView APKs since it never leaves the page. */
|
||
.media-lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
background: rgba(0, 0, 0, .92);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px
|
||
}
|
||
|
||
.media-lightbox-img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
user-select: none
|
||
}
|
||
|
||
.media-lightbox-video {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
outline: none;
|
||
}
|
||
|
||
.media-lightbox-audio {
|
||
width: min(420px, 90vw);
|
||
outline: none;
|
||
}
|
||
|
||
.media-lightbox-close {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, .15);
|
||
color: #fff;
|
||
font-size: 22px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center
|
||
}
|
||
|
||
.media-lightbox-close:hover {
|
||
background: rgba(255, 255, 255, .3)
|
||
}
|
||
|
||
.media-lightbox-speed {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 64px;
|
||
min-width: 44px;
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
border: none;
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, .15);
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-tap-highlight-color: transparent
|
||
}
|
||
|
||
.media-lightbox-speed:hover {
|
||
background: rgba(255, 255, 255, .3)
|
||
}
|
||
|
||
/* BEGIN telemirror */
|
||
/* Sidebar trigger — small "↗" icon signals leaving the main flow.
|
||
inline-flex would override .stb's text-align:center, so use
|
||
inline-block and inline-block the icon alongside the text. */
|
||
.tm-sidebar-btn-ext {
|
||
display: inline-block;
|
||
margin-inline-start: 4px;
|
||
font-size: 11px; opacity: .65; line-height: 1;
|
||
vertical-align: baseline
|
||
}
|
||
.tm-sidebar-btn:hover .tm-sidebar-btn-ext { opacity: 1 }
|
||
|
||
.tm-no-scroll { overflow: hidden }
|
||
.tm-modal {
|
||
position: fixed; inset: 0; z-index: 9000;
|
||
background: var(--bg);
|
||
display: none;
|
||
flex-direction: column;
|
||
color: var(--text)
|
||
}
|
||
.tm-modal.active { display: flex }
|
||
|
||
.tm-topbar {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 16px; min-height: 56px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: linear-gradient(180deg, var(--bg-elevated, var(--bg)), var(--bg));
|
||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||
flex-shrink: 0
|
||
}
|
||
.tm-back, .tm-menu {
|
||
background: none; border: 1px solid transparent; color: var(--text);
|
||
font-size: 16px; cursor: pointer; padding: 6px 10px; border-radius: 8px;
|
||
transition: background .12s, border-color .12s
|
||
}
|
||
.tm-back:hover, .tm-menu:hover { background: var(--border); border-color: var(--border) }
|
||
/* .tm-menu visibility handled inside the same @media block as the sidebar drawer (below). */
|
||
.tm-menu { display: none }
|
||
|
||
.tm-topbar-info { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0 }
|
||
.tm-topbar-meta { min-width: 0; flex: 1 }
|
||
.tm-topbar-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
|
||
.tm-topbar-sub { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px }
|
||
|
||
.tm-avatar {
|
||
border-radius: 50%;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
color: #fff; font-weight: 600; font-size: 16px;
|
||
overflow: hidden; flex-shrink: 0;
|
||
background: var(--accent);
|
||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), 0 1px 2px rgba(0,0,0,.15);
|
||
position: relative
|
||
}
|
||
.tm-avatar img {
|
||
position: absolute; inset: 0;
|
||
width: 100%; height: 100%; object-fit: cover; display: block
|
||
}
|
||
.tm-avatar-initial {
|
||
pointer-events: none; user-select: none;
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 100%; height: 100%
|
||
}
|
||
|
||
.tm-body { flex: 1; min-height: 0; display: flex; position: relative }
|
||
.tm-sidebar {
|
||
width: 320px; flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
background: var(--bg-elevated, var(--bg));
|
||
display: flex; flex-direction: column; min-height: 0
|
||
}
|
||
/* Backdrop sits between the sidebar (z:2) and the content. In mobile
|
||
drawer mode it lights up when .tm-sidebar.open is set so tapping
|
||
outside the drawer dismisses it. */
|
||
.tm-backdrop { display: none }
|
||
|
||
/* Mobile drawer. The hamburger menu also shows up here, plus on
|
||
touch devices up to 1024px so phones in landscape and tablets
|
||
in portrait still get the drawer treatment. */
|
||
@media (max-width: 768px), (pointer: coarse) and (max-width: 1024px) {
|
||
.tm-menu { display: inline-flex }
|
||
.tm-sidebar {
|
||
position: absolute; top: 0; bottom: 0;
|
||
width: 86%; max-width: 340px;
|
||
z-index: 2; transition: transform .22s ease;
|
||
box-shadow: 0 0 0 rgba(0,0,0,.0)
|
||
}
|
||
/* LTR: slide in from the left. */
|
||
html[dir="ltr"] .tm-sidebar { left: 0; right: auto; transform: translateX(-100%) }
|
||
html[dir="ltr"] .tm-sidebar.open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0,0,0,.32) }
|
||
/* RTL: slide in from the right (where the channel list belongs). */
|
||
html[dir="rtl"] .tm-sidebar { right: 0; left: auto; transform: translateX(100%) }
|
||
html[dir="rtl"] .tm-sidebar.open { transform: translateX(0); box-shadow: -4px 0 24px rgba(0,0,0,.32) }
|
||
|
||
.tm-backdrop {
|
||
display: block;
|
||
position: absolute; inset: 0;
|
||
background: rgba(0,0,0,0);
|
||
pointer-events: none;
|
||
transition: background .22s ease;
|
||
z-index: 1
|
||
}
|
||
.tm-sidebar.open ~ .tm-backdrop {
|
||
background: rgba(0,0,0,.42);
|
||
pointer-events: auto
|
||
}
|
||
}
|
||
.tm-disclaimer {
|
||
padding: 12px 14px; margin: 10px;
|
||
font-size: 11px; color: var(--text-dim); line-height: 1.6;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border); border-radius: 10px;
|
||
border-inline-start: 3px solid var(--accent)
|
||
}
|
||
.tm-add-row {
|
||
display: flex; gap: 6px; padding: 0 10px 10px;
|
||
border-bottom: 1px solid var(--border)
|
||
}
|
||
.tm-add-row input {
|
||
flex: 1; padding: 8px 12px; font-size: 13px;
|
||
background: var(--bg); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 8px;
|
||
font-family: inherit;
|
||
transition: border-color .12s
|
||
}
|
||
.tm-add-row input:focus { outline: none; border-color: var(--accent) }
|
||
.tm-add-row .btn { padding: 8px 14px; font-size: 13px; border-radius: 8px }
|
||
|
||
.tm-channels-list { flex: 1; overflow-y: auto; padding: 6px }
|
||
.tm-channel-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 9px 10px; cursor: pointer;
|
||
border-radius: 10px;
|
||
border: 1px solid transparent;
|
||
transition: background .12s, border-color .12s;
|
||
margin-bottom: 2px
|
||
}
|
||
.tm-channel-item:hover { background: var(--bg); border-color: var(--border) }
|
||
.tm-channel-item.active {
|
||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||
border-color: var(--accent)
|
||
}
|
||
.tm-channel-item.active .tm-channel-item-name { color: var(--accent) }
|
||
.tm-channel-item-meta { flex: 1; min-width: 0 }
|
||
.tm-channel-item-name {
|
||
font-size: 13px; font-weight: 600;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis
|
||
}
|
||
.tm-pin { font-size: 9px; opacity: .55; margin-inline-start: 4px }
|
||
.tm-x {
|
||
background: none; border: 1px solid transparent; color: inherit;
|
||
font-size: 16px; cursor: pointer; opacity: .45;
|
||
padding: 2px 7px; border-radius: 6px;
|
||
transition: background .12s, opacity .12s
|
||
}
|
||
.tm-x:hover { opacity: 1; background: var(--border) }
|
||
|
||
.tm-content {
|
||
flex: 1; overflow-y: auto;
|
||
padding: 18px 16px 40px;
|
||
background:
|
||
radial-gradient(circle at 25% 0%, color-mix(in oklab, var(--accent) 6%, transparent), transparent 40%),
|
||
var(--bg);
|
||
display: flex; flex-direction: column; gap: 12px
|
||
}
|
||
.tm-empty {
|
||
margin: auto; padding: 32px 24px;
|
||
text-align: center; color: var(--text-dim); font-size: 13px
|
||
}
|
||
|
||
/* Channel bio — clearly distinct from posts: dashed border, label header, accent left edge. */
|
||
.tm-channel-bio {
|
||
max-width: 720px; width: 100%; margin: 0 auto 8px;
|
||
background: color-mix(in oklab, var(--accent) 5%, transparent);
|
||
border: 1px dashed color-mix(in oklab, var(--accent) 50%, var(--border));
|
||
border-radius: 12px;
|
||
overflow: hidden
|
||
}
|
||
.tm-channel-bio-label {
|
||
padding: 8px 14px;
|
||
font-size: 11px; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: .04em;
|
||
color: var(--accent);
|
||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||
border-bottom: 1px dashed color-mix(in oklab, var(--accent) 35%, var(--border))
|
||
}
|
||
.tm-channel-bio-body {
|
||
padding: 12px 14px;
|
||
font-size: 13px; line-height: 1.75;
|
||
color: var(--text-dim);
|
||
overflow-wrap: anywhere; word-break: break-word
|
||
}
|
||
.tm-channel-bio-body a { color: var(--accent) }
|
||
|
||
.tm-post {
|
||
max-width: 720px; width: 100%; margin: 0 auto;
|
||
background: var(--bg-elevated, var(--bg));
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
padding: 12px 16px 10px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||
transition: border-color .15s, transform .15s
|
||
}
|
||
.tm-post:hover { border-color: color-mix(in oklab, var(--accent) 35%, var(--border)) }
|
||
.tm-post-head {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 11px; color: var(--text-dim);
|
||
margin-bottom: 8px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px dashed var(--border)
|
||
}
|
||
.tm-post-author { font-weight: 700; color: var(--accent) }
|
||
.tm-post-time { opacity: .85 }
|
||
.tm-post-edited {
|
||
font-size: 10px; padding: 1px 6px; border-radius: 4px;
|
||
background: var(--border); color: var(--text-dim)
|
||
}
|
||
.tm-post-text {
|
||
font-size: 14px; line-height: 1.7;
|
||
overflow-wrap: anywhere; /* break long URLs / unbroken strings */
|
||
word-break: break-word
|
||
}
|
||
.tm-post-text pre, .tm-post-text code {
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere
|
||
}
|
||
.tm-post-text a {
|
||
color: var(--accent); text-decoration: underline;
|
||
text-decoration-color: color-mix(in oklab, var(--accent) 50%, transparent);
|
||
overflow-wrap: anywhere
|
||
}
|
||
.tm-post-text a:hover { text-decoration-color: currentColor }
|
||
.tm-post-media {
|
||
margin-top: 10px;
|
||
display: grid; gap: 4px
|
||
}
|
||
/* Single photo: fill width, natural aspect, capped height. */
|
||
.tm-album-1 { grid-template-columns: 1fr }
|
||
.tm-album-1 .tm-photo img { max-height: 600px; object-fit: contain; background: #000 }
|
||
/* 2 photos side by side. */
|
||
.tm-album-2 { grid-template-columns: 1fr 1fr }
|
||
.tm-album-2 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||
/* 3+ photos: 3-column album. */
|
||
.tm-album-3 { grid-template-columns: 1fr 1fr 1fr }
|
||
.tm-album-3 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||
|
||
.tm-photo {
|
||
position: relative;
|
||
display: block; line-height: 0;
|
||
border-radius: 10px; overflow: hidden;
|
||
background: #000;
|
||
border: 1px solid var(--border)
|
||
}
|
||
.tm-photo img {
|
||
width: 100%; height: auto; display: block
|
||
}
|
||
/* Download badge — fades in on hover; always visible on touch screens. */
|
||
.tm-photo-dl {
|
||
position: absolute; bottom: 8px; inset-inline-end: 8px;
|
||
width: 32px; height: 32px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
background: rgba(0,0,0,.65); color: #fff;
|
||
border-radius: 50%;
|
||
font-size: 16px; text-decoration: none;
|
||
opacity: 0; transition: opacity .15s, background .15s;
|
||
backdrop-filter: blur(2px)
|
||
}
|
||
.tm-photo:hover .tm-photo-dl { opacity: 1 }
|
||
.tm-photo-dl:hover { background: rgba(0,0,0,.85) }
|
||
@media (hover: none) {
|
||
.tm-photo-dl { opacity: .85 }
|
||
}
|
||
.tm-vid {
|
||
position: relative; display: flex;
|
||
align-items: center; justify-content: center;
|
||
aspect-ratio: 16 / 9;
|
||
background-size: cover; background-position: center;
|
||
border-radius: 10px; background-color: var(--border);
|
||
color: #fff;
|
||
text-shadow: 0 0 6px rgba(0,0,0,.6);
|
||
border: 1px solid var(--border)
|
||
}
|
||
.tm-vid-play {
|
||
width: 54px; height: 54px;
|
||
border-radius: 50%; background: rgba(0,0,0,.5);
|
||
backdrop-filter: blur(4px);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: 22px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.4)
|
||
}
|
||
.tm-vid-dur {
|
||
position: absolute; bottom: 8px; right: 10px;
|
||
background: rgba(0,0,0,.65);
|
||
padding: 2px 7px; border-radius: 5px;
|
||
font-size: 11px
|
||
}
|
||
.tm-post-foot {
|
||
display: flex; align-items: center; gap: 14px;
|
||
margin-top: 10px;
|
||
padding-top: 6px;
|
||
border-top: 1px dashed var(--border);
|
||
font-size: 11px; color: var(--text-dim)
|
||
}
|
||
.tm-views { display: inline-flex; align-items: center; gap: 4px }
|
||
/* Time at the end of the foot (i18n-friendly with start-end). */
|
||
.tm-post-foot .tm-post-time { margin-inline-start: auto }
|
||
|
||
/* Topbar refresh button */
|
||
.tm-refresh {
|
||
background: none; border: 1px solid transparent; color: var(--text);
|
||
font-size: 18px; cursor: pointer; padding: 6px 10px; border-radius: 8px;
|
||
transition: background .12s, border-color .12s, transform .15s
|
||
}
|
||
.tm-refresh:hover { background: var(--border); border-color: var(--border) }
|
||
.tm-refresh:active { transform: rotate(45deg) }
|
||
|
||
/* Copy button — text label, sits at the end of post-head. */
|
||
.tm-post-copy {
|
||
margin-inline-start: auto;
|
||
background: none; border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
padding: 3px 10px;
|
||
border-radius: 6px;
|
||
transition: background .12s, color .12s, border-color .12s
|
||
}
|
||
.tm-post-copy:hover { background: var(--border); color: var(--text); border-color: var(--accent) }
|
||
.tm-post-copy.tm-copied { color: var(--accent); border-color: var(--accent); background: color-mix(in oklab, var(--accent) 10%, transparent) }
|
||
|
||
/* Generic media tile (voice, audio, document, poll). */
|
||
.tm-media-tile {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
min-width: 0
|
||
}
|
||
.tm-media-icon {
|
||
flex-shrink: 0;
|
||
width: 40px; height: 40px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
border-radius: 50%;
|
||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||
font-size: 18px
|
||
}
|
||
.tm-media-meta { flex: 1; min-width: 0 }
|
||
.tm-media-title {
|
||
font-size: 13px; font-weight: 600;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis
|
||
}
|
||
.tm-media-sub { font-size: 11px; color: var(--text-dim); margin-top: 2px }
|
||
|
||
/* Sticker — small inline image, no border. */
|
||
.tm-sticker {
|
||
max-width: 200px; line-height: 0
|
||
}
|
||
.tm-sticker img {
|
||
max-width: 100%; max-height: 200px; display: block; background: transparent
|
||
}
|
||
|
||
/* Reactions row — pills of emoji + count. */
|
||
.tm-post-reactions {
|
||
display: flex; flex-wrap: wrap; gap: 6px;
|
||
margin-top: 10px
|
||
}
|
||
.tm-reaction {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 9px;
|
||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
font-size: 12px
|
||
}
|
||
.tm-reaction-emoji { font-size: 14px; line-height: 1 }
|
||
.tm-reaction-count { color: var(--text-dim); font-weight: 600 }
|
||
|
||
/* If our image proxy fails, collapse the wrapper so we don't show
|
||
a permanent broken-image icon. */
|
||
.tm-photo-failed { display: none }
|
||
|
||
/* Confirm dialog rendered inside the telemirror modal so it sits
|
||
on top of the drawer / backdrop / posts at the right stacking. */
|
||
.tm-confirm-overlay {
|
||
position: absolute; inset: 0;
|
||
z-index: 10;
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 16px;
|
||
background: rgba(0,0,0,.55);
|
||
animation: tm-fade-in .15s ease
|
||
}
|
||
@keyframes tm-fade-in { from { opacity: 0 } to { opacity: 1 } }
|
||
.tm-confirm-box {
|
||
max-width: 380px; width: 100%;
|
||
background: var(--bg-elevated, var(--bg));
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
padding: 18px 20px 14px;
|
||
box-shadow: 0 12px 32px rgba(0,0,0,.4)
|
||
}
|
||
.tm-confirm-msg {
|
||
margin: 0 0 14px;
|
||
font-size: 13px; line-height: 1.7;
|
||
color: var(--text)
|
||
}
|
||
.tm-confirm-actions {
|
||
display: flex; gap: 8px; justify-content: flex-end
|
||
}
|
||
|
||
/* First-visit hint — points the user at the channel list. */
|
||
.tm-first-hint {
|
||
margin: auto;
|
||
padding: 28px 24px;
|
||
max-width: 420px;
|
||
text-align: center;
|
||
color: var(--text-dim);
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
background: var(--bg-elevated, var(--bg));
|
||
border: 1px dashed var(--border);
|
||
border-radius: 14px
|
||
}
|
||
.tm-first-hint-arrow {
|
||
font-size: 32px; line-height: 1;
|
||
color: var(--accent);
|
||
margin-bottom: 10px
|
||
}
|
||
.tm-first-hint-text { white-space: pre-line }
|
||
/* END telemirror */
|
||
|
||
.media-action {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-radius: 999px;
|
||
border: none;
|
||
background: rgba(0, 0, 0, .55);
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background .15s
|
||
}
|
||
|
||
.media-action:hover {
|
||
background: rgba(0, 0, 0, .75)
|
||
}
|
||
|
||
.media-action.media-loading {
|
||
background: var(--accent)
|
||
}
|
||
|
||
.media-action-disabled {
|
||
cursor: not-allowed;
|
||
opacity: .55
|
||
}
|
||
|
||
/* Non-downloadable variant: red ✕ inside the action chip. Clicking it
|
||
opens a small modal that explains why the file isn't available. */
|
||
.media-action-blocked {
|
||
background: rgba(229, 57, 53, .8)
|
||
}
|
||
|
||
.media-action-blocked:hover {
|
||
background: var(--error)
|
||
}
|
||
|
||
.media-icon-blocked {
|
||
font-weight: 700
|
||
}
|
||
|
||
.media-file-btn-blocked {
|
||
background: var(--error)
|
||
}
|
||
|
||
.media-file-btn-blocked:hover {
|
||
background: var(--error);
|
||
filter: brightness(.9)
|
||
}
|
||
|
||
.media-icon {
|
||
font-size: 16px;
|
||
line-height: 1
|
||
}
|
||
|
||
.media-meta {
|
||
font-size: 12px;
|
||
opacity: .92
|
||
}
|
||
|
||
.media-progress {
|
||
position: absolute;
|
||
left: 12px;
|
||
right: 12px;
|
||
bottom: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px
|
||
}
|
||
|
||
.media-progress-bar {
|
||
height: 4px;
|
||
background: rgba(255, 255, 255, .2);
|
||
border-radius: 2px;
|
||
overflow: hidden
|
||
}
|
||
|
||
.media-progress-bar-thin {
|
||
margin-top: 4px;
|
||
background: var(--border)
|
||
}
|
||
|
||
.media-progress-fill {
|
||
height: 100%;
|
||
width: 0%;
|
||
background: var(--accent);
|
||
transition: width .3s ease-out
|
||
}
|
||
|
||
.media-progress-text {
|
||
font-size: 11px;
|
||
color: #fff;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, .55)
|
||
}
|
||
|
||
/* File-style row */
|
||
.media-file {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
background: var(--surface2);
|
||
max-width: 380px
|
||
}
|
||
|
||
.media-file-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.media-file-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
direction: ltr
|
||
}
|
||
|
||
.media-file-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.media-file-size {
|
||
font-size: 11px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.media-file-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
text-decoration: none
|
||
}
|
||
|
||
.media-file-btn:hover {
|
||
background: var(--accent-hover)
|
||
}
|
||
|
||
.media-file-btn-disabled {
|
||
background: var(--border);
|
||
cursor: not-allowed
|
||
}
|
||
|
||
.media-file-btn-ready {
|
||
background: var(--success)
|
||
}
|
||
|
||
.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;
|
||
padding: 8px 14px;
|
||
background: var(--surface);
|
||
border-top: 1px solid var(--border);
|
||
gap: 8px;
|
||
align-items: flex-end
|
||
}
|
||
|
||
.send-panel.visible {
|
||
display: flex
|
||
}
|
||
|
||
.send-input {
|
||
flex: 1;
|
||
padding: 8px 14px;
|
||
border: none;
|
||
border-radius: 18px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
outline: none;
|
||
resize: none;
|
||
max-height: 120px;
|
||
min-height: 36px
|
||
}
|
||
|
||
.send-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: var(--send-color);
|
||
color: #fff;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.send-btn:hover {
|
||
background: var(--accent-hover)
|
||
}
|
||
|
||
/* ===== PROGRESS ===== */
|
||
.progress-panel {
|
||
background: var(--surface);
|
||
border-top: 1px solid var(--border);
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
padding: 0 14px;
|
||
direction: ltr;
|
||
text-align: left;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.progress-panel:empty {
|
||
display: none
|
||
}
|
||
|
||
.progress-item {
|
||
padding: 5px 0;
|
||
position: relative;
|
||
padding-right: 22px
|
||
}
|
||
|
||
.progress-close {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 4px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: 0 2px
|
||
}
|
||
|
||
.progress-close:hover {
|
||
color: var(--text)
|
||
}
|
||
|
||
.progress-label {
|
||
font-size: 11px;
|
||
margin-bottom: 2px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 3px;
|
||
background: var(--border);
|
||
border-radius: 2px;
|
||
overflow: hidden
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
transition: width .2s
|
||
}
|
||
|
||
@keyframes prog-pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1
|
||
}
|
||
|
||
50% {
|
||
opacity: .4
|
||
}
|
||
}
|
||
|
||
@keyframes spin {
|
||
from {
|
||
transform: rotate(0deg)
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg)
|
||
}
|
||
}
|
||
|
||
@keyframes badge-pulse {
|
||
|
||
0%,
|
||
100% {
|
||
box-shadow: 0 0 0 0 rgba(51, 144, 236, .4)
|
||
}
|
||
|
||
50% {
|
||
box-shadow: 0 0 0 4px rgba(51, 144, 236, 0)
|
||
}
|
||
}
|
||
|
||
.refresh-has-new {
|
||
animation: badge-pulse 2s ease-in-out infinite;
|
||
color: var(--accent) !important
|
||
}
|
||
|
||
.msg-copy-btn {
|
||
background: none;
|
||
border: 1px solid var(--text-dim);
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
padding: 1px 8px;
|
||
line-height: 1.4;
|
||
flex-shrink: 0;
|
||
opacity: .6;
|
||
transition: opacity .15s, border-color .15s;
|
||
font-family: inherit;
|
||
border-radius: 4px
|
||
}
|
||
|
||
.msg-copy-btn:hover {
|
||
opacity: 1;
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
.msg-new-sep {
|
||
text-align: center;
|
||
padding: 6px 0;
|
||
font-size: 12px;
|
||
color: var(--accent)
|
||
}
|
||
|
||
.msg-new-sep span {
|
||
background: rgba(51, 144, 236, .12);
|
||
padding: 3px 14px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(51, 144, 236, .25)
|
||
}
|
||
|
||
.theme-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 4px
|
||
}
|
||
|
||
.theme-btn {
|
||
flex: 1;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
transition: all .15s
|
||
}
|
||
|
||
.theme-btn:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
/* ===== SETTINGS SECTIONS ===== */
|
||
.settings-section {
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--border)
|
||
}
|
||
|
||
.settings-section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
margin-bottom: 8px
|
||
}
|
||
|
||
.settings-info-row {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 10px
|
||
}
|
||
|
||
.settings-info-row span:last-child {
|
||
font-family: monospace;
|
||
color: var(--text)
|
||
}
|
||
|
||
/* Password status */
|
||
.password-status {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
margin-top: 8px
|
||
}
|
||
|
||
.password-status-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--text)
|
||
}
|
||
|
||
.password-status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent)
|
||
}
|
||
|
||
.theme-btn.active-theme {
|
||
border-color: var(--accent);
|
||
background: rgba(51, 144, 236, .12);
|
||
color: var(--accent);
|
||
font-weight: 600
|
||
}
|
||
|
||
/* ===== SCROLL-TO-BOTTOM ===== */
|
||
.scroll-down-btn {
|
||
position: absolute;
|
||
bottom: 70px;
|
||
right: 16px;
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
font-size: 18px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, .45);
|
||
z-index: 10;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border)
|
||
}
|
||
|
||
.scroll-down-btn.visible {
|
||
display: flex
|
||
}
|
||
|
||
.scroll-down-btn:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.scroll-down-badge {
|
||
position: absolute;
|
||
top: -4px;
|
||
right: -4px;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 3px
|
||
}
|
||
|
||
.scroll-down-badge.visible {
|
||
display: flex
|
||
}
|
||
|
||
/* ===== LOG ===== */
|
||
.log-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 3px 14px;
|
||
background: var(--bg2);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
letter-spacing: .5px;
|
||
border-top: 1px solid var(--border)
|
||
}
|
||
|
||
.log-toggle:hover {
|
||
color: var(--text)
|
||
}
|
||
|
||
.log-panel {
|
||
height: 110px;
|
||
background: var(--bg2);
|
||
overflow-y: auto;
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
padding: 4px 14px;
|
||
direction: ltr;
|
||
text-align: left;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.log-panel.hidden {
|
||
display: none;
|
||
height: 0
|
||
}
|
||
|
||
.log-line {
|
||
padding: 1px 0;
|
||
line-height: 1.3
|
||
}
|
||
|
||
.log-line.err {
|
||
color: #e53935
|
||
}
|
||
|
||
.log-line.ok {
|
||
color: #4fae4e
|
||
}
|
||
|
||
.log-line.warn {
|
||
color: #f97316
|
||
}
|
||
|
||
.log-line.prog {
|
||
color: #fbbf24
|
||
}
|
||
|
||
.log-line.inf {
|
||
color: #60a5fa
|
||
}
|
||
|
||
/* ===== EMPTY STATE ===== */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: var(--text-dim);
|
||
gap: 14px;
|
||
text-align: center;
|
||
padding: 20px
|
||
}
|
||
|
||
.empty-state .big-icon {
|
||
font-size: 52px;
|
||
opacity: .25
|
||
}
|
||
|
||
.empty-state p {
|
||
font-size: 14px;
|
||
max-width: 240px
|
||
}
|
||
|
||
/* ===== MODALS ===== */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, .65);
|
||
z-index: 100;
|
||
justify-content: center;
|
||
align-items: center
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
width: 440px;
|
||
max-width: 95vw;
|
||
max-height: 90vh;
|
||
max-height: 90dvh;
|
||
overflow-y: auto;
|
||
direction: rtl
|
||
}
|
||
|
||
html[dir=ltr] .modal {
|
||
direction: ltr
|
||
}
|
||
|
||
.modal h2 {
|
||
margin-bottom: 16px;
|
||
font-size: 17px;
|
||
font-weight: 600
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap
|
||
}
|
||
|
||
/* Small "i" button next to a modal title that toggles a hidden
|
||
description block. Replaces always-shown info-notes that bloat
|
||
the modal vertically. */
|
||
.title-info-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
padding: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
font-family: serif;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1;
|
||
transition: color 120ms, border-color 120ms, background 120ms
|
||
}
|
||
|
||
.title-info-btn:hover,
|
||
.title-info-btn[aria-expanded="true"] {
|
||
color: var(--accent);
|
||
border-color: var(--accent);
|
||
background: var(--card-bg)
|
||
}
|
||
|
||
/* Bank scoreboard rows — vertical stack of cards, two columns:
|
||
a "main" block with address + a wrapped stats line, and a
|
||
fixed-width actions column on the trailing edge. The action
|
||
buttons stay visible on phone widths because the main column
|
||
flexes/wraps instead of pushing the actions off-screen. */
|
||
.rb-rows {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px
|
||
}
|
||
|
||
.rb-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: var(--card-bg)
|
||
}
|
||
|
||
.rb-row-main {
|
||
flex: 1;
|
||
min-width: 0
|
||
}
|
||
|
||
.rb-row-addr {
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap
|
||
}
|
||
|
||
.rb-row-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 3px;
|
||
font-size: 11px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.rb-row-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.rb-row-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
padding: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
transition: background 120ms, border-color 120ms, color 120ms
|
||
}
|
||
|
||
.rb-row-add {
|
||
color: var(--accent)
|
||
}
|
||
|
||
.rb-row-add:hover {
|
||
background: var(--card-bg);
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
.rb-row-del {
|
||
color: var(--error)
|
||
}
|
||
|
||
.rb-row-del:hover {
|
||
background: var(--card-bg);
|
||
border-color: var(--error)
|
||
}
|
||
|
||
/* Resolver Bank modal header — title + (i) on one side, Bank
|
||
chip on the other. */
|
||
.resolver-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px
|
||
}
|
||
|
||
.resolver-header h2 {
|
||
flex: 1;
|
||
min-width: 0
|
||
}
|
||
|
||
/* ===== RESOLVER LIST TABS ===== */
|
||
/* Tabs strip that lives at the top of the Resolver Bank modal.
|
||
Lists, "+" and Bank are arranged in two visually distinct
|
||
groups: lists scroll horizontally on narrow screens and look
|
||
like chips; Bank is pinned to the trailing edge with its own
|
||
chip style because it points to the master pool, not a list. */
|
||
.resolver-tabs-wrap {
|
||
position: relative;
|
||
margin-bottom: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px
|
||
}
|
||
|
||
/* The scrolling strip uses a CSS-only "scroll shadow" trick that
|
||
fades the trailing edge into the modal surface, hinting that
|
||
there's more content to scroll. The fade soft-edges the visible
|
||
content on both sides so a user who's never noticed horizontal
|
||
scroll has an obvious cue. */
|
||
.resolver-tabs {
|
||
display: flex;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
scrollbar-width: thin;
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 2px 0;
|
||
-webkit-mask-image: linear-gradient(90deg, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||
mask-image: linear-gradient(90deg, transparent 0, black 14px, black calc(100% - 14px), transparent 100%)
|
||
}
|
||
|
||
.resolver-tabs::-webkit-scrollbar {
|
||
height: 4px
|
||
}
|
||
|
||
.resolver-tabs::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 2px
|
||
}
|
||
|
||
/* Each list-tab looks like a chip — a real button you can see,
|
||
even on a flat dark background. Hover/selected states stay
|
||
subtle but legible. */
|
||
.rtab {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--border);
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
border-radius: 999px;
|
||
transition: background 120ms, border-color 120ms, color 120ms;
|
||
position: relative
|
||
}
|
||
|
||
.rtab:hover {
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
.rtab-active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
font-weight: 600
|
||
}
|
||
|
||
.rtab-active:hover {
|
||
filter: brightness(1.1)
|
||
}
|
||
|
||
.rtab-count {
|
||
font-size: 11px;
|
||
padding: 1px 8px;
|
||
border-radius: 999px;
|
||
background: rgba(0, 0, 0, 0.18);
|
||
color: inherit;
|
||
font-weight: 500;
|
||
min-width: 18px;
|
||
text-align: center
|
||
}
|
||
|
||
.rtab:not(.rtab-active) .rtab-count {
|
||
background: var(--bg);
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.rtab-active .rtab-count {
|
||
background: rgba(255, 255, 255, 0.22);
|
||
color: #fff
|
||
}
|
||
|
||
.rtab-menu-btn {
|
||
width: 18px;
|
||
height: 18px;
|
||
padding: 0;
|
||
margin-inline-start: 2px;
|
||
border: none;
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
opacity: 0.85
|
||
}
|
||
|
||
.rtab-menu-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(255, 255, 255, 0.18)
|
||
}
|
||
|
||
/* Plus chip — dashed outline to read as "add new" without looking
|
||
like a regular list-tab. */
|
||
.rtab-add {
|
||
color: var(--text-dim);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
padding: 7px 14px;
|
||
border-style: dashed;
|
||
background: transparent
|
||
}
|
||
|
||
.rtab-add:hover {
|
||
color: var(--accent);
|
||
background: var(--card-bg)
|
||
}
|
||
|
||
/* Bank "tab" sits outside the scrolling strip with its own look —
|
||
it's a different concept (master pool, not a saved list). */
|
||
.rtab-bank {
|
||
flex-shrink: 0;
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
border-radius: 8px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: background 120ms, border-color 120ms, color 120ms
|
||
}
|
||
|
||
.rtab-bank:hover {
|
||
border-color: var(--text-dim);
|
||
color: var(--text)
|
||
}
|
||
|
||
.rtab-bank.rtab-active {
|
||
background: var(--card-bg);
|
||
border-color: var(--text);
|
||
color: var(--text);
|
||
font-weight: 600
|
||
}
|
||
|
||
.rtab-bank-icon {
|
||
font-size: 14px;
|
||
line-height: 1
|
||
}
|
||
|
||
/* Inline rename / delete popover anchored to the selected tab's ⋮.
|
||
Uses fixed positioning so it sits at viewport coordinates derived
|
||
from getBoundingClientRect — avoids breaking when ancestors lose
|
||
position:relative or get a mask-image (which forms a containing
|
||
block at unexpected times). */
|
||
.resolver-tab-menu {
|
||
position: fixed;
|
||
z-index: 10001;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
min-width: 140px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35)
|
||
}
|
||
|
||
.resolver-tab-menu[hidden] {
|
||
display: none
|
||
}
|
||
|
||
.resolver-tab-menu button {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
text-align: start;
|
||
cursor: pointer;
|
||
border-radius: 6px
|
||
}
|
||
|
||
.resolver-tab-menu button:hover {
|
||
background: var(--card-bg)
|
||
}
|
||
|
||
.resolver-tab-menu button:last-child:hover {
|
||
color: var(--error)
|
||
}
|
||
|
||
/* ===== FORMS ===== */
|
||
.form-group {
|
||
margin-bottom: 12px
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group textarea,
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
direction: ltr;
|
||
text-align: left;
|
||
outline: none
|
||
}
|
||
|
||
.form-group input:focus,
|
||
.form-group textarea:focus,
|
||
.form-group select:focus {
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
.form-group textarea {
|
||
min-height: 65px;
|
||
resize: vertical
|
||
}
|
||
|
||
.form-group .row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center
|
||
}
|
||
|
||
.form-group .row input[type=checkbox] {
|
||
width: auto;
|
||
accent-color: var(--accent)
|
||
}
|
||
|
||
.form-group .row label {
|
||
margin: 0;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--text)
|
||
}
|
||
|
||
/* ===== BUTTONS ===== */
|
||
.btn {
|
||
padding: 7px 16px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: background .15s;
|
||
font-family: inherit
|
||
}
|
||
|
||
.btn-flat {
|
||
background: transparent;
|
||
color: var(--accent)
|
||
}
|
||
|
||
.btn-flat:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
color: #fff
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--accent-hover)
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--error);
|
||
color: #fff
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #c62828
|
||
}
|
||
|
||
.btn-outline {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--text)
|
||
}
|
||
|
||
.btn-outline:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 4px 10px;
|
||
font-size: 11px
|
||
}
|
||
|
||
/* ===== SETTINGS MODAL ===== */
|
||
.font-size-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 14px
|
||
}
|
||
|
||
.font-size-row label {
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
white-space: nowrap
|
||
}
|
||
|
||
.font-size-row input[type=range] {
|
||
flex: 1;
|
||
accent-color: var(--accent)
|
||
}
|
||
|
||
.font-size-val {
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
min-width: 28px;
|
||
text-align: center;
|
||
background: var(--bg);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border)
|
||
}
|
||
|
||
.lang-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 4px
|
||
}
|
||
|
||
.lang-btn {
|
||
flex: 1;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
font-size: 13px;
|
||
transition: all .15s
|
||
}
|
||
|
||
.lang-btn:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.lang-btn.active-lang {
|
||
border-color: var(--accent);
|
||
background: rgba(51, 144, 236, .12);
|
||
color: var(--accent);
|
||
font-weight: 600
|
||
}
|
||
|
||
/* ===== PROFILES MODAL ===== */
|
||
.profile-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-bottom: 1px solid var(--border)
|
||
}
|
||
|
||
.profile-row-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
transition: background .1s
|
||
}
|
||
|
||
.profile-row-main:hover {
|
||
background: var(--hover)
|
||
}
|
||
|
||
.profile-row.active-profile .profile-row-main {
|
||
background: rgba(51, 144, 236, .08)
|
||
}
|
||
|
||
.profile-row-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.profile-row.active-profile .profile-row-avatar {
|
||
background: var(--success)
|
||
}
|
||
|
||
.profile-row-info {
|
||
flex: 1;
|
||
min-width: 0
|
||
}
|
||
|
||
.profile-row-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis
|
||
}
|
||
|
||
.profile-row-domain {
|
||
font-size: 11px;
|
||
color: var(--text-dim)
|
||
}
|
||
|
||
.profile-row-btns {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-shrink: 0
|
||
}
|
||
|
||
.active-badge {
|
||
display: inline-block;
|
||
font-size: 9px;
|
||
padding: 1px 6px;
|
||
border-radius: 8px;
|
||
background: var(--success);
|
||
color: #fff;
|
||
margin-left: 6px;
|
||
vertical-align: middle
|
||
}
|
||
|
||
html[dir=ltr] .active-badge {
|
||
margin-left: 0;
|
||
margin-right: 6px
|
||
}
|
||
|
||
.section-divider {
|
||
border: none;
|
||
border-top: 1px solid var(--border);
|
||
margin: 14px 0
|
||
}
|
||
|
||
.info-note {
|
||
background: rgba(51, 144, 236, .08);
|
||
border: 1px solid rgba(51, 144, 236, .2);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
margin-bottom: 10px;
|
||
line-height: 1.5
|
||
}
|
||
|
||
.tg-warning {
|
||
background: rgba(229, 57, 53, .1);
|
||
border: 1px solid rgba(229, 57, 53, .3);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
color: #ef9a9a;
|
||
margin-bottom: 12px;
|
||
display: none
|
||
}
|
||
|
||
.tg-warning.visible {
|
||
display: block
|
||
}
|
||
|
||
.import-section {
|
||
margin-top: 14px
|
||
}
|
||
|
||
.import-row {
|
||
display: flex;
|
||
gap: 8px
|
||
}
|
||
|
||
.import-row input {
|
||
flex: 1;
|
||
padding: 7px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
direction: ltr;
|
||
text-align: left;
|
||
outline: none
|
||
}
|
||
|
||
.import-row input:focus {
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
/* ===== CHANNEL EDITOR IN PROFILE ===== */
|
||
.channel-editor-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 8px
|
||
}
|
||
|
||
.channel-editor-row input {
|
||
flex: 1;
|
||
padding: 7px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
direction: ltr;
|
||
text-align: left;
|
||
outline: none
|
||
}
|
||
|
||
.channel-editor-row input:focus {
|
||
border-color: var(--accent)
|
||
}
|
||
|
||
.channel-list-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid var(--border)
|
||
}
|
||
|
||
.channel-list-item:last-child {
|
||
border-bottom: none
|
||
}
|
||
|
||
.channel-list-item span {
|
||
font-size: 13px;
|
||
direction: ltr
|
||
}
|
||
|
||
/* ===== TOAST ===== */
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(20px);
|
||
background: #333;
|
||
color: #fff;
|
||
padding: 8px 18px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
opacity: 0;
|
||
transition: all .25s;
|
||
z-index: 999;
|
||
pointer-events: none;
|
||
white-space: nowrap
|
||
}
|
||
|
||
#toast.show {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0)
|
||
}
|
||
|
||
/* ===== MOBILE ===== */
|
||
.header-kebab {
|
||
position: relative;
|
||
display: none
|
||
}
|
||
|
||
.header-kebab-menu {
|
||
position: absolute;
|
||
right: 0;
|
||
top: calc(100% + 4px);
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,.2);
|
||
min-width: 150px;
|
||
z-index: 100;
|
||
display: none;
|
||
flex-direction: column;
|
||
overflow: hidden
|
||
}
|
||
|
||
.header-kebab-menu.open { display: flex }
|
||
|
||
.header-kebab-menu button {
|
||
width: 100%;
|
||
padding: 11px 16px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
text-align: left
|
||
}
|
||
|
||
.header-kebab-menu button:hover { background: var(--hover) }
|
||
|
||
@media(max-width:768px) {
|
||
/* On mobile, the body's safe-area padding covers iOS notch/home bar.
|
||
Inside, the app fills the remaining space. */
|
||
.app {
|
||
position: relative;
|
||
overflow: hidden;
|
||
height: 100%
|
||
}
|
||
|
||
.sidebar {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
z-index: 2;
|
||
transform: translateX(0);
|
||
transition: transform .25s ease
|
||
}
|
||
|
||
.chat-area {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1;
|
||
transform: translateX(100%);
|
||
transition: transform .25s ease
|
||
}
|
||
|
||
.app.chat-open .sidebar {
|
||
transform: translateX(-100%)
|
||
}
|
||
|
||
.app.chat-open .chat-area {
|
||
transform: translateX(0)
|
||
}
|
||
|
||
.back-btn {
|
||
display: flex
|
||
}
|
||
|
||
.header-search-btn, .header-export-btn { display: none }
|
||
|
||
.header-kebab { display: flex }
|
||
|
||
.msg {
|
||
max-width: 90%
|
||
}
|
||
|
||
/* Modals on mobile become bottom sheets — make sure they reach the
|
||
visible bottom edge and aren't hidden behind the browser nav bar
|
||
or iOS home indicator. */
|
||
.modal-overlay {
|
||
height: 100dvh
|
||
}
|
||
|
||
.modal {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
border-radius: 0;
|
||
border-left: none;
|
||
border-right: none;
|
||
border-bottom: none;
|
||
margin-top: auto;
|
||
align-self: flex-end;
|
||
padding-bottom: max(20px, calc(20px + env(safe-area-inset-bottom)))
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
align-items: flex-end
|
||
}
|
||
|
||
/* Add a little breathing room at the very bottom of the messages list
|
||
and send panel on mobile so the last message / input isn't crammed
|
||
against the browser nav bar (when safe-area-inset is 0). */
|
||
.messages {
|
||
padding-bottom: max(10px, env(safe-area-inset-bottom))
|
||
}
|
||
}
|
||
|
||
/* When the on-screen keyboard or browser nav bars pop up on mobile, the
|
||
visual viewport may shrink. Force the app shell to follow it. */
|
||
@supports (height: 100dvh) {
|
||
html, body { height: 100dvh }
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 4px
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 2px
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="app" id="app">
|
||
|
||
<!-- SIDEBAR -->
|
||
<div class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="sidebar-header-top">
|
||
<button class="profile-btn" id="profileBtn" onclick="openProfiles()">
|
||
<div class="profile-btn-avatar" id="profileBtnAvatar">?</div>
|
||
<span class="profile-btn-name" id="profileBtnName" data-i18n="set_up">Set Up</span>
|
||
<span class="profile-btn-arrow"><span class="plus">+</span>▼</span>
|
||
</button>
|
||
<button class="icon-btn" onclick="openSettings()" title="Settings" data-i18n-title="settings">⚙</button>
|
||
<button class="icon-btn" onclick="jumpToLog()" title="Log" data-i18n-title="sidebar_log" style="font-size:16px">📜</button>
|
||
</div>
|
||
<div class="sidebar-toolbar">
|
||
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="scanner_find_resolvers">Find Resolvers</button>
|
||
<button class="stb" id="resolversSidebarBtn" onclick="openResolversModal()"><span data-i18n="sidebar_resolvers">Resolvers</span> <span class="stb-badge" id="resolversBadge" style="color:var(--error)">0</span></button>
|
||
<!-- BEGIN telemirror -->
|
||
<button class="stb tm-sidebar-btn" id="telemirrorSidebarBtn" onclick="openTelemirror()" data-i18n-title="telemirror_btn_title"><span data-i18n="telemirror_btn">Browse channels</span><span class="tm-sidebar-btn-ext" aria-hidden="true">↗</span></button>
|
||
<!-- END telemirror -->
|
||
</div>
|
||
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
||
oninput="filterChannels()">
|
||
</div>
|
||
<div class="channel-list" id="channelList">
|
||
<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:13px" data-i18n="no_channels">No
|
||
channels yet</div>
|
||
</div>
|
||
<div class="sidebar-footer">
|
||
TELEGRAM: <a href="https://t.me/networkti" target="_blank">@networkti</a>
|
||
·
|
||
<a href="https://github.com/sartoopjj/thefeed" target="_blank">GitHub</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHAT AREA -->
|
||
<div class="chat-area">
|
||
<div class="chat-header">
|
||
<button class="back-btn" onclick="openSidebar()">←</button>
|
||
<div class="chat-header-info">
|
||
<div class="chat-header-name" id="chatName">thefeed</div>
|
||
<div class="chat-header-sub" id="chatSub"></div>
|
||
</div>
|
||
<div class="chat-header-actions">
|
||
<span class="next-fetch-label" id="nextFetchTimer"></span><span class="next-fetch-info" id="nextFetchInfoBtn"
|
||
style="display:none" data-i18n-title="next_fetch_info" title="" onclick="showToast(t('next_fetch_info'))"
|
||
tabindex="0">ⓘ</span>
|
||
<button class="icon-btn header-search-btn" onclick="toggleMsgSearch()" title="Search" data-i18n-title="search_messages"
|
||
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="search_messages">Search</button>
|
||
<button class="icon-btn header-export-btn" onclick="openExportModal()" title="Export" data-i18n-title="export_messages"
|
||
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="export_messages">Export</button>
|
||
<button class="icon-btn" id="refreshBtn" onclick="doRefreshUI()" title="Refresh"
|
||
style="width:40px;height:40px;font-size:20px">↻</button>
|
||
<div class="header-kebab" id="headerKebab">
|
||
<button class="icon-btn" onclick="toggleKebabMenu(event)" title="More" style="width:40px;height:40px;font-size:20px">⋮</button>
|
||
<div class="header-kebab-menu" id="headerKebabMenu">
|
||
<button onclick="toggleMsgSearch();closeKebabMenu()" data-i18n="search_messages">Search</button>
|
||
<button onclick="openExportModal();closeKebabMenu()" data-i18n="export_messages">Export</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="msg-search-bar" id="msgSearchBar">
|
||
<input type="text" id="msgSearchInput" data-i18n-ph="search_messages" placeholder="Search messages..." oninput="doMsgSearch()">
|
||
<div class="search-nav">
|
||
<span id="msgSearchCount"></span>
|
||
<button onclick="msgSearchPrev()" title="Previous">▲</button>
|
||
<button onclick="msgSearchNext()" title="Next">▼</button>
|
||
<button onclick="closeMsgSearch()" title="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="messages" id="messages">
|
||
<div class="empty-state">
|
||
<div class="big-icon">📡</div>
|
||
<p data-i18n="configure_server">Configure a server to start reading</p>
|
||
<button class="btn btn-primary" onclick="openProfiles()" data-i18n="set_up">Set Up</button>
|
||
</div>
|
||
</div>
|
||
<div class="send-panel" id="sendPanel">
|
||
<input class="send-input" id="sendInput" data-i18n-ph="write_message" placeholder="Write a message..."
|
||
maxlength="4000">
|
||
<button class="send-btn" onclick="sendMessage()">➤</button>
|
||
</div>
|
||
<button class="scroll-down-btn" id="scrollDownBtn" onclick="scrollToBottom()" title="Jump to latest">↓<span
|
||
class="scroll-down-badge" id="scrollDownBadge"></span></button>
|
||
<div class="progress-panel" id="progressPanel"></div>
|
||
<div class="log-toggle" onclick="toggleLog()"><span>LOG</span><span id="logToggleIcon">▶</span></div>
|
||
<div class="log-panel hidden" id="logPanel"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TOAST -->
|
||
<div id="toast"></div>
|
||
|
||
<!-- BEGIN telemirror -->
|
||
<div id="telemirrorModal" class="tm-modal">
|
||
<header class="tm-topbar">
|
||
<button class="tm-back" onclick="closeTelemirror()" aria-label="Close">❮</button>
|
||
<button class="tm-menu" onclick="toggleTmSidebar()" aria-label="Channels">☰</button>
|
||
<div class="tm-topbar-info">
|
||
<div id="tmTopbarAvatar"></div>
|
||
<div class="tm-topbar-meta">
|
||
<div class="tm-topbar-name" id="tmTopbarName" data-i18n="telemirror_title">Browse channels</div>
|
||
<div class="tm-topbar-sub" id="tmTopbarSub"></div>
|
||
</div>
|
||
</div>
|
||
<button class="tm-refresh" onclick="tmRefreshActive()" title="Refresh" data-i18n-title="telemirror_refresh">↻</button>
|
||
</header>
|
||
<div class="tm-body">
|
||
<aside id="tmSidebar" class="tm-sidebar">
|
||
<p class="tm-disclaimer" data-i18n="telemirror_disclaimer">Experimental backup feature for browsing public Telegram channels. Depends on third-party services and may stop working or be rate-limited. For reliable access, use the core thefeed feature.</p>
|
||
<div class="tm-add-row">
|
||
<input id="tmAddInput" type="text" data-i18n-ph="telemirror_add_ph" placeholder="@username" maxlength="40">
|
||
<button class="btn btn-primary" onclick="telemirrorAdd()" data-i18n="add">Add</button>
|
||
</div>
|
||
<div id="tmChannelsList" class="tm-channels-list"></div>
|
||
</aside>
|
||
<div class="tm-backdrop" onclick="toggleTmSidebar()" aria-hidden="true"></div>
|
||
<main id="tmContent" class="tm-content">
|
||
<div class="tm-empty" data-i18n="telemirror_pick_channel">Pick a channel</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
<!-- END telemirror -->
|
||
|
||
<!-- ===== SAVED RESOLVERS POPUP ===== -->
|
||
<div class="modal-overlay" id="savedResolversModal">
|
||
<div class="modal" style="max-width:380px">
|
||
<h2 data-i18n="saved_resolvers_title">Quick Start</h2>
|
||
<p id="savedResolversMsg" style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6"></p>
|
||
<!-- "Don't show again" lives on its own row so it reads as a
|
||
secondary preference, not just another action button.
|
||
Toggling it sets a localStorage flag the user can clear
|
||
from Settings → Show startup scan prompt. -->
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:10px">
|
||
<button class="btn btn-flat" id="savedResolversNever" onclick="savedResolversNever()"
|
||
data-i18n="dont_show_again" style="font-size:11px;padding:4px 10px;color:var(--text-dim)">Don't show again</button>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="savedResolversSkip()" data-i18n="saved_resolvers_skip">Skip</button>
|
||
<button class="btn btn-outline" id="savedResolversRescanBtn" onclick="savedResolversRescan()" data-i18n="saved_resolvers_rescan">Scan
|
||
Again</button>
|
||
<button class="btn btn-primary" id="savedResolversUseBtn" onclick="savedResolversUseNow()" data-i18n="saved_resolvers_use">Use
|
||
Now</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== SETTINGS MODAL ===== -->
|
||
<div class="modal-overlay" id="settingsModal">
|
||
<div class="modal">
|
||
<h2 data-i18n="settings">⚙ Settings</h2>
|
||
<div class="font-size-row">
|
||
<label data-i18n="font_size">Font Size</label>
|
||
<input type="range" id="fontSizeSlider" min="11" max="22" value="14" oninput="previewFontSize(this.value)">
|
||
<span class="font-size-val" id="fontSizeVal">14</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="row">
|
||
<input type="checkbox" id="cfgDebug">
|
||
<label for="cfgDebug" data-i18n="debug_mode">Debug mode</label>
|
||
</div>
|
||
</div>
|
||
<!-- Re-enable the "Quick start" / scan prompt that appears on
|
||
launch. Mirrors localStorage thefeed_scan_prompt_off so the
|
||
user can opt back in after dismissing it via the prompt's
|
||
own "Don't show again" button. -->
|
||
<div class="form-group">
|
||
<div class="row">
|
||
<input type="checkbox" id="cfgShowScanPrompt" onchange="setShowScanPrompt(this.checked)">
|
||
<label for="cfgShowScanPrompt" data-i18n="show_scan_prompt">Show scan prompt on startup</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="language">Language</label>
|
||
<div class="lang-row">
|
||
<button class="lang-btn" id="langFa" onclick="setLang('fa')">فارسی</button>
|
||
<button class="lang-btn" id="langEn" onclick="setLang('en')">English</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="theme">Theme</label>
|
||
<div class="theme-row">
|
||
<button class="theme-btn" id="themeDark" onclick="setTheme('dark')" data-i18n="theme_dark">Dark</button>
|
||
<button class="theme-btn" id="themeLight" onclick="setTheme('light')" data-i18n="theme_light">Light</button>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-info-row" style="margin-top:0">
|
||
<span data-i18n="version">Version</span>
|
||
<span id="appVersionEl">-</span>
|
||
</div>
|
||
<div class="settings-info-row">
|
||
<span data-i18n="latest_version">Latest Version</span>
|
||
<span id="latestVersionEl">-</span>
|
||
</div>
|
||
<div class="settings-info-row" style="display:flex;gap:6px">
|
||
<button class="btn btn-outline btn-sm" id="checkVersionBtn" onclick="checkLatestVersion()"
|
||
data-i18n="check_now" style="flex:1">Check Now</button>
|
||
<button class="btn btn-outline btn-sm" id="checkGitHubBtn" onclick="checkGitHubUpdate(true)"
|
||
data-i18n="check_github" style="flex:1">Check on GitHub</button>
|
||
</div>
|
||
<div class="settings-info-row">
|
||
<button class="btn btn-flat btn-sm" onclick="clearCache()"
|
||
style="color:var(--danger,#e74c3c);width:100%" data-i18n="clear_cache_btn">Clear Cache</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:12px">
|
||
<label data-i18n="bg_image">Background Image</label>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<input type="file" id="bgImageInput" accept="image/*" style="flex:1;font-size:12px;color:var(--text)" onchange="applyBgImage()">
|
||
<button class="btn btn-flat btn-sm" onclick="clearBgImage()" style="color:var(--error)" data-i18n="clear_bg">Clear</button>
|
||
</div>
|
||
</div>
|
||
<!-- Android-only: App Password -->
|
||
<div id="androidPasswordSection" class="settings-section" style="display:none">
|
||
<div class="settings-section-title"><span data-i18n="app_password">App Password</span></div>
|
||
<div id="passwordSetSection" style="margin-top:8px">
|
||
<input type="password" id="appPasswordInput" placeholder=""
|
||
data-i18n-ph="password_new_ph"
|
||
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box;margin-bottom:6px">
|
||
<input type="password" id="appPasswordConfirm" placeholder=""
|
||
data-i18n-ph="password_confirm_ph"
|
||
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box">
|
||
<div id="passwordError" style="color:var(--error);font-size:12px;margin-top:4px;display:none"></div>
|
||
<div style="margin-top:8px">
|
||
<button class="btn btn-primary" onclick="setAppPassword()" style="font-size:12px;padding:6px 14px" data-i18n="password_set">Set Password</button>
|
||
</div>
|
||
</div>
|
||
<div id="passwordRemoveSection" style="display:none">
|
||
<div class="password-status">
|
||
<div class="password-status-left">
|
||
<span class="password-status-dot"></span>
|
||
<span data-i18n="password_active">Password is active</span>
|
||
</div>
|
||
<button class="btn btn-flat btn-sm" onclick="showPasswordRemovePrompt()" style="color:var(--error)" data-i18n="password_change_remove">Change / Remove</button>
|
||
</div>
|
||
<div id="passwordRemovePrompt" style="display:none;margin-top:8px">
|
||
<input type="password" id="appPasswordCurrent" placeholder=""
|
||
data-i18n-ph="password_current_ph"
|
||
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box">
|
||
<div id="passwordRemoveError" style="color:var(--error);font-size:12px;margin-top:4px;display:none"></div>
|
||
<div style="display:flex;gap:6px;margin-top:8px">
|
||
<button class="btn btn-primary" onclick="changeAppPassword()" style="font-size:12px;padding:6px 14px" data-i18n="password_change">Change</button>
|
||
<button class="btn btn-flat" onclick="removeAppPassword()" style="font-size:12px;padding:6px 14px;color:var(--error)" data-i18n="password_remove">Remove</button>
|
||
<button class="btn btn-flat" onclick="hidePasswordRemovePrompt()" style="font-size:12px;padding:6px 14px" data-i18n="cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="closeSettings()" data-i18n="cancel">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="save">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== PROFILES MODAL ===== -->
|
||
<div class="modal-overlay" id="profilesModal">
|
||
<div class="modal">
|
||
<h2 data-i18n="profiles">Profiles</h2>
|
||
<!-- Import URI at top -->
|
||
<div class="import-section"
|
||
style="margin-top:0;margin-bottom:14px;padding:10px 12px;background:var(--bg);border-radius:8px;border:1px solid var(--border)">
|
||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px" data-i18n="import_uri_label">Import URI
|
||
</div>
|
||
<div class="import-row">
|
||
<input id="importUriInput" placeholder="thefeed://..." data-i18n-ph="import_uri_ph">
|
||
<button class="btn btn-flat btn-sm" type="button" onclick="pasteImportUri()" data-i18n-title="paste" title="Paste" aria-label="Paste" style="font-size:14px">📋</button>
|
||
<button class="btn btn-primary btn-sm" onclick="doImportUri()" data-i18n="import">Import</button>
|
||
</div>
|
||
<div id="importError" style="color:var(--error);font-size:12px;display:none;margin-top:6px"></div>
|
||
<div id="importSuccess" style="color:var(--success);font-size:12px;display:none;margin-top:6px"></div>
|
||
</div>
|
||
<div id="profilesListEl"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="closeProfiles()" data-i18n="close">Close</button>
|
||
<button class="btn btn-outline" onclick="openProfileEditor(null)" data-i18n="add_manual">✎ Create
|
||
Manually</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== SHARE PROFILE MODAL ===== -->
|
||
<!-- Standalone modal to keep the share UI from cramming the inline
|
||
profile row. Source dropdown picks Bank or any named list,
|
||
checkboxes filter individual resolvers, URI is copy-able, QR
|
||
renders the same URI for cross-device share. -->
|
||
<div class="modal-overlay" id="shareProfileModal">
|
||
<div class="modal" style="max-width:440px">
|
||
<h2 style="margin-top:0"><span data-i18n="share">Share</span> · <span id="shareModalProfileName" style="color:var(--text-dim);font-weight:400"></span></h2>
|
||
<div class="form-group">
|
||
<label data-i18n="share_source">Source</label>
|
||
<select id="shareModalSource" onchange="reloadShareModalResolvers()"></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||
<label style="margin:0" data-i18n="select_resolvers_export">Resolvers to include</label>
|
||
<div style="display:flex;gap:4px">
|
||
<button type="button" class="btn btn-flat" style="font-size:11px;padding:3px 8px" onclick="toggleAllShareModalResolvers(true)" data-i18n="select_all">All</button>
|
||
<button type="button" class="btn btn-flat" style="font-size:11px;padding:3px 8px" onclick="toggleAllShareModalResolvers(false)" data-i18n="select_none">None</button>
|
||
</div>
|
||
</div>
|
||
<div id="shareModalResolvers" style="max-height:160px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:6px;font-size:12px;font-family:monospace"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="share_uri">Share URI</label>
|
||
<textarea id="shareModalUri" readonly rows="3" style="width:100%;font-family:monospace;font-size:12px;resize:vertical;box-sizing:border-box"></textarea>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="closeShareModal()" data-i18n="close">Close</button>
|
||
<button class="btn btn-primary" onclick="copyShareModalUri()" data-i18n="copy">Copy</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== PROFILE EDITOR MODAL ===== -->
|
||
<div class="modal-overlay" id="profileEditorModal">
|
||
<div class="modal">
|
||
<h2 id="profileEditorTitle" data-i18n="new_profile">New Profile</h2>
|
||
<div id="peWarning" class="tg-warning"></div>
|
||
<div class="form-group"><label data-i18n="nickname">Nickname</label><input id="peNick" maxlength="32" placeholder="My Server">
|
||
</div>
|
||
<div class="form-group"><label data-i18n="domain">Domain</label><input id="peDomain" placeholder="t.example.com">
|
||
</div>
|
||
<div class="form-group"><label data-i18n="passphrase">Passphrase</label><input type="password" id="peKey"
|
||
placeholder="..."></div>
|
||
<div class="info-note" style="font-size:12px;line-height:1.5;margin:6px 0 10px;padding:8px 12px;background:var(--card-bg);border-radius:8px;border:1px solid var(--border)">
|
||
<span data-i18n="resolver_bank_note">Resolvers are managed in the shared Resolver Bank.</span>
|
||
<a href="#" onclick="event.preventDefault();closeProfileEditor();openResolversModal()" style="color:var(--accent);text-decoration:none;margin-inline-start:4px" data-i18n="open_resolver_bank">Open Resolver Bank</a>
|
||
</div>
|
||
<div class="form-group"><label data-i18n="query_mode">Query Mode</label>
|
||
<select id="peQueryMode">
|
||
<option value="single">Single label (base32)</option>
|
||
<option value="double">Multi-label (hex)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group"><label data-i18n="rate_limit">Concurrent block fetches</label><input type="number"
|
||
id="peRateLimit" value="6" min="0" step="0.1"></div>
|
||
<div class="form-group"><label data-i18n="scatter">Parallel resolvers per block</label><input type="number" id="peScatter"
|
||
value="4" min="1" max="5" title="How many resolvers are queried at the same time for one block"></div>
|
||
<div class="form-group"><label data-i18n="dns_timeout">DNS Query Timeout (seconds)</label><input type="number" id="peTimeout"
|
||
value="15" min="1" max="60" step="1"></div>
|
||
<!-- Channel Management (editing only) -->
|
||
<div id="peChannelSection" style="display:none">
|
||
<hr class="section-divider">
|
||
<div style="font-size:14px;font-weight:600;margin-bottom:8px" data-i18n="channels">Channels</div>
|
||
<div class="info-note" id="peChannelNote" data-i18n="channel_mgmt_note">Channel management requires server-side
|
||
support.</div>
|
||
<div class="channel-editor-row" id="peAddChannelRow" style="display:none">
|
||
<input id="peAddChannelInput" data-i18n-ph="channel_placeholder" placeholder="channel_username">
|
||
<button class="btn btn-primary btn-sm" onclick="addChannelEditor()" data-i18n="add">Add</button>
|
||
</div>
|
||
<div id="peChannelList" style="max-height:150px;overflow-y:auto"></div>
|
||
</div>
|
||
<div id="peError" style="color:var(--error);font-size:12px;display:none;margin-top:8px"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="closeProfileEditor()" data-i18n="cancel">Cancel</button>
|
||
<button class="btn btn-danger" id="peDeleteBtn" style="display:none" onclick="deleteEditingProfile()"
|
||
data-i18n="delete">Delete</button>
|
||
<button class="btn btn-primary" onclick="saveProfile()" data-i18n="save">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== EXPORT MODAL ===== -->
|
||
<div class="modal-overlay" id="exportModal">
|
||
<div class="modal" style="max-width:380px">
|
||
<h2 data-i18n="export_title">Copy Messages</h2>
|
||
<div class="export-row">
|
||
<label data-i18n="export_count">Number of messages</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.max(1,parseInt(e.value||1)-1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">−</button>
|
||
<input type="number" id="exportCount" value="10" min="1" max="500" style="width:70px;text-align:center">
|
||
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.min(parseInt(e.max)||500,parseInt(e.value||0)+1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-flat" onclick="closeExportModal()" data-i18n="cancel">Cancel</button>
|
||
<button class="btn btn-primary" onclick="doExport()" data-i18n="export_copy">Copy to Clipboard</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== RESOLVER BANK MODAL ===== -->
|
||
<div class="modal-overlay" id="resolversModal">
|
||
<div class="modal" style="max-width:600px">
|
||
<!-- Header row: title + (i) on the leading edge, Bank chip on
|
||
the trailing edge. Putting them together keeps the modal
|
||
compact and surfaces the Bank toggle without consuming a
|
||
second row of vertical space. -->
|
||
<div class="resolver-header">
|
||
<h2 style="margin:0;display:flex;align-items:center;gap:8px">
|
||
<span data-i18n="resolvers_title">Resolver Bank</span>
|
||
<button type="button" class="title-info-btn" id="resolverBankInfoBtn"
|
||
onclick="toggleResolverBankInfo()"
|
||
data-i18n-title="info" title="Info" aria-label="Info"
|
||
aria-controls="resolverBankInfoPanel" aria-expanded="false">i</button>
|
||
</h2>
|
||
<!-- Bank chip — populated by renderBankPill(). Lives in the
|
||
header so it doesn't crowd the list-tabs strip. -->
|
||
<div id="resolverBankSlot"></div>
|
||
</div>
|
||
<div class="info-note" id="resolverBankInfoPanel" hidden
|
||
style="font-size:12px;line-height:1.5;margin-bottom:12px;padding:8px 12px;background:var(--card-bg);border-radius:8px;border:1px solid var(--border)">
|
||
<span data-i18n="resolver_bank_info">Resolvers are DNS servers used to connect to thefeed and fetch data. Use the Scanner to find new resolvers, or add them manually below.</span>
|
||
</div>
|
||
<!-- List tabs — every named resolver list plus a "+" tab to
|
||
create new ones. Switching tabs hot-swaps the fetcher's
|
||
active resolvers. The selected list-tab shows a ⋮ inline
|
||
for rename / delete. Rendered by renderResolverTabs(). -->
|
||
<div class="resolver-tabs-wrap">
|
||
<div class="resolver-tabs" id="resolverTabs" role="tablist" aria-label="Resolver lists"></div>
|
||
</div>
|
||
<!-- Inline tab menu (rename / delete) for the selected list-tab.
|
||
Positioned over the tab strip via JS so it can be dismissed
|
||
by clicking outside. -->
|
||
<div class="resolver-tab-menu" id="resolverTabMenu" role="menu" hidden>
|
||
<button type="button" onclick="renameCurrentResolverList()" data-i18n="rename" role="menuitem">Rename</button>
|
||
<button type="button" onclick="deleteCurrentResolverList()" data-i18n="delete" role="menuitem">Delete</button>
|
||
</div>
|
||
<!-- Bank → list picker. Opened by clicking "+" on a bank row.
|
||
Body is filled in dynamically with the user's named lists
|
||
plus the resolver address being added. Reuses
|
||
.resolver-tab-menu styling. -->
|
||
<div class="resolver-tab-menu" id="bankAddMenu" role="menu" hidden></div>
|
||
<!-- Active resolvers panel -->
|
||
<div id="resolverPanelActive" style="max-height:350px;overflow-y:auto;font-size:13px;margin-bottom:10px"></div>
|
||
<!-- Bank resolvers panel -->
|
||
<div id="resolverPanelBank" style="display:none">
|
||
<div id="resolverBankListEl" style="max-height:280px;overflow-y:auto;font-size:13px;margin-bottom:10px"></div>
|
||
<!-- Cleanup section -->
|
||
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:6px">
|
||
<div style="font-size:13px;font-weight:600;margin-bottom:6px" data-i18n="cleanup_title">Remove Bad Resolvers</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<label style="font-size:12px;white-space:nowrap" data-i18n="min_score">Min score:</label>
|
||
<input type="range" id="bankCleanupSlider" min="0.01" max="0.5" step="0.01" value="0.10" style="flex:1" oninput="previewBankCleanup()">
|
||
<span id="bankCleanupValue" style="font-size:12px;font-weight:600;min-width:36px;text-align:center">0.10</span>
|
||
</div>
|
||
<div id="bankCleanupPreview" style="font-size:12px;color:var(--text-dim);margin-bottom:8px"></div>
|
||
<button class="btn btn-danger btn-sm" onclick="doBankCleanup()" data-i18n="remove_bad_resolvers">Remove Bad Resolvers</button>
|
||
</div>
|
||
<!-- Add resolvers manually -->
|
||
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:10px">
|
||
<div style="font-size:13px;font-weight:600;margin-bottom:6px" data-i18n="add_resolvers">Add Resolvers</div>
|
||
<div style="display:flex;gap:6px">
|
||
<textarea id="bankAddResolvers" rows="2" placeholder="8.8.8.8 1.1.1.1" style="flex:1;font-family:monospace;font-size:12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 8px"></textarea>
|
||
<button class="btn btn-primary btn-sm" onclick="addResolversToBank()" style="align-self:flex-end" data-i18n="add">Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:10px;display:flex;align-items:center;gap:8px">
|
||
<input type="checkbox" id="bankAutoScan" style="width:auto" checked onchange="toggleBankAutoScan()">
|
||
<label for="bankAutoScan" style="font-size:12px;cursor:pointer" data-i18n="auto_scan">Automatic hourly resolver check</label>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-outline" onclick="openScannerFromBank()" data-i18n="scanner_find_resolvers">🔍 Find Resolvers</button>
|
||
<button class="btn btn-outline" onclick="doRescanFromBank()" data-i18n="rescan">Rescan</button>
|
||
<button class="btn btn-outline" onclick="resetScoreboard()" data-i18n="reset_scoreboard">Reset Scores</button>
|
||
<button class="btn btn-outline" onclick="copyResolversList()" data-i18n="copy">Copy</button>
|
||
<button class="btn btn-flat" onclick="closeResolversModal()" data-i18n="close">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== SCANNER MODAL ===== -->
|
||
<div class="modal-overlay" id="scannerModal">
|
||
<div class="modal" style="max-width:520px">
|
||
<h2 data-i18n="scanner_title">🔍 Resolver Scanner</h2>
|
||
|
||
<!-- Collapsible help -->
|
||
<div style="margin-bottom:14px">
|
||
<p id="scannerAboutShort" style="font-size:13px;color:var(--text-dim);line-height:1.5;margin:0">
|
||
<span data-i18n="scanner_about_short">Scan IP ranges to find DNS resolvers that work with your server.</span>
|
||
<a href="#" id="scannerReadMoreLink" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='';this.style.display='none'" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_more">Read more...</a>
|
||
</p>
|
||
<p id="scannerAboutFull" style="display:none;font-size:12px;color:var(--text-dim);line-height:1.6;margin:0"><span data-i18n="scanner_about">This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.</span> <a href="#" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='none';document.getElementById('scannerReadMoreLink').style.display=''" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_less">Show less</a></p>
|
||
</div>
|
||
|
||
<!-- Config section -->
|
||
<div id="scannerConfig">
|
||
<div class="form-group">
|
||
<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='';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" 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">
|
||
<label data-i18n="scanner_profile">Profile</label>
|
||
<select id="scanProfile" style="width:100%"></select>
|
||
</div>
|
||
<details style="margin-bottom:12px">
|
||
<summary style="cursor:pointer;font-size:13px;color:var(--text-dim);user-select:none" data-i18n="scanner_advanced">Advanced options</summary>
|
||
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||
<div class="form-group">
|
||
<label data-i18n="query_mode">Query Mode</label>
|
||
<select id="scanQueryMode" style="width:100%">
|
||
<option value="single">Single label</option>
|
||
<option value="double">Multi-label</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="scanner_rate_limit">Concurrency</label>
|
||
<input type="number" id="scanRateLimit" value="50" min="1" max="500">
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="scanner_timeout">Timeout (s)</label>
|
||
<input type="number" id="scanTimeout" value="10" min="1" max="60">
|
||
</div>
|
||
<div class="form-group">
|
||
<label data-i18n="scanner_max_ips">Max IPs (0=all)</label>
|
||
<input type="number" id="scanMaxIPs" value="0" min="0">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:4px">
|
||
<div class="row" style="gap:6px;align-items:center">
|
||
<input type="checkbox" id="scanExpand">
|
||
<label for="scanExpand" style="font-size:13px" data-i18n="scanner_expand_subnet">Expand /24 — scan nearby IPs when a resolver is found</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Progress section -->
|
||
<div id="scannerProgressSection" style="display:none">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||
<div style="font-size:14px;color:var(--text)">
|
||
<strong id="scanStatusLabel">-</strong>
|
||
</div>
|
||
<div style="font-size:13px;color:var(--text-dim)">
|
||
<span id="scanProgressText">0 / 0</span> — <span id="scanFoundText">0</span> <span data-i18n="scanner_found">found</span>
|
||
</div>
|
||
</div>
|
||
<div style="height:6px;border-radius:3px;margin-bottom:14px;background:var(--border);overflow:hidden">
|
||
<div id="scanProgressFill" style="width:0%;height:100%;border-radius:3px;background:var(--accent);transition:width .3s"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results section -->
|
||
<div id="scannerResults" style="display:none;max-height:220px;overflow-y:auto;margin-bottom:14px;border:1px solid var(--border);border-radius:8px">
|
||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||
<thead><tr style="background:var(--bg);position:sticky;top:0;z-index:1">
|
||
<th style="padding:8px;text-align:left;width:32px"><input type="checkbox" id="scanSelectAll" checked onchange="toggleScanSelectAll(this.checked)"></th>
|
||
<th style="padding:8px;text-align:left">IP</th>
|
||
<th style="padding:8px;text-align:right" data-i18n="scanner_latency">ms</th>
|
||
<th style="padding:8px;width:36px"></th>
|
||
</tr></thead>
|
||
<tbody id="scanResultsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Action bar — only "Add" remains. Overwrite was removed
|
||
because it desyncs named lists (lists kept references to
|
||
resolvers that the bank no longer had). Use Bank → ✕ to
|
||
prune individually; that path also propagates removals. -->
|
||
<div id="scannerApplySection" style="display:none;margin-bottom:14px">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button class="btn btn-primary" style="flex:1;min-width:0;padding:10px 0;font-size:14px" onclick="applyScanResults('append')">
|
||
<span data-i18n="scanner_append">Add to bank</span> <span id="scanAppendCount" style="opacity:.7"></span>
|
||
</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:8px">
|
||
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copySelectedScanResults()">
|
||
<span data-i18n="copy">Copy</span> <span id="scanCopyCount" style="opacity:.7"></span>
|
||
</button>
|
||
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copyAllScanResults()" data-i18n="scanner_copy_all">Copy All</button>
|
||
<button class="btn btn-outline" style="flex:1;padding:8px 0;font-size:13px;font-weight:600" onclick="resetScannerUI()" data-i18n="scanner_new_scan">↺ New Scan</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions" style="gap:8px;flex-wrap:wrap">
|
||
<button class="btn btn-flat" onclick="closeScanner()" data-i18n="close" style="padding:10px 20px;font-size:14px">Close</button>
|
||
<div style="flex:1"></div>
|
||
<button class="btn btn-outline" id="scanPauseBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="toggleScanPause()" data-i18n="scanner_pause">Pause</button>
|
||
<button class="btn btn-danger" id="scanStopBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="stopScan()" data-i18n="scanner_stop">Stop</button>
|
||
<button class="btn btn-primary" id="scanStartBtn" onclick="startScan()" data-i18n="scanner_start" style="padding:10px 24px;font-size:14px">Start Scan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ===== i18n =====
|
||
var I18N = {
|
||
fa: {
|
||
search: 'جستجو...', settings: 'تنظیمات', profiles: 'پروفایلها',
|
||
no_channels: 'هنوز کانالی دریافت نشده', loading: 'در حال بارگذاری...', no_messages: 'هنوز پیامی در این کانال وجود ندارد',
|
||
no_channels_hint: 'در پسزمینه داریم کار میکنیم — برای دیدن جزئیات روی',
|
||
no_channels_hint2: 'کلیک کنید',
|
||
scanning_resolvers: 'در حال بررسی ریزالورها',
|
||
server_fetch_wait: 'سرور در حال دریافت اطلاعات جدید از تلگرام',
|
||
no_messages_hint: 'برنامه در حال دریافت پیامها است. لطفاً چند لحظه صبر کنید...',
|
||
download: 'دانلود', media_too_large: 'حجم فایل بیش از حد مجاز سرور است', media_failed: 'دانلود ناموفق بود',
|
||
downloading: 'در حال دانلود...', media_open: 'باز کردن', media_play: 'پخش', media_save: 'ذخیره', media_share: 'اشتراکگذاری', close: 'بستن',
|
||
queued: 'در صف', media_hash_mismatch: 'هش محتوا با پیام مطابقت ندارد',
|
||
blocks_label: 'قطعه',
|
||
media_blocked_title: 'برای دانلود در دسترس نیست',
|
||
media_blocked_too_large: 'حجم این فایل ({size}) بیشتر از سقف کش سرور ({limit}) است.',
|
||
media_blocked_unavailable: 'سرور این فایل را کش نکرده است؛ احتمالاً از سقف {limit} بزرگتر است یا دریافت آن ناموفق بوده.',
|
||
media_blocked_generic: 'سرور این فایل را برای دانلود ذخیره نکرده است.',
|
||
ok: 'باشه',
|
||
cancel_media_msg: 'دانلود این رسانه لغو شود؟', dismiss: 'بستن',
|
||
write_message: 'پیام بنویسید...', configure_server: 'برای شروع یک سرور راهاندازی کنید',
|
||
set_up: 'راهاندازی', switching: 'در حال تغییر پروفایل...', select_channel_hint: 'یک کانال را برای دیدن پیامها انتخاب کنید',
|
||
// BEGIN telemirror
|
||
telemirror_btn: 'دیدن کانالهای دلخواه',
|
||
telemirror_btn_title: 'دیدن کانالهای دلخواه',
|
||
telemirror_title: 'دیدن کانالهای دلخواه',
|
||
telemirror_disclaimer: 'روش باز کردن کانالهای دلخواه در این بخش فقط تا زمانی که سرویسهای گوگل باز باشن کار میکنه. اگر براتون کار نکرد، از قابلیتهای اصلی thefeed استفاده کنید چون ضدفیلتر هستن.',
|
||
telemirror_add_ph: 'مثلا @VahidOnline',
|
||
telemirror_loading: 'در حال بارگذاری...',
|
||
telemirror_load_failed: 'دریافت کانال ناموفق بود.',
|
||
telemirror_no_posts: 'هیچ پستی پیدا نشد.',
|
||
telemirror_pick_channel: 'یک کانال را انتخاب کن',
|
||
telemirror_invalid_user: 'نام کاربری نامعتبر',
|
||
telemirror_remove_pinned: 'این کانال پیشفرض قابل حذف نیست',
|
||
telemirror_views: 'بازدید',
|
||
telemirror_edited: 'ویرایش شده',
|
||
telemirror_first_hint: 'یک کانال از لیست انتخاب کن، یا با ورودی بالا کانال جدیدی اضافه کن.',
|
||
telemirror_first_hint_mobile: 'برای دیدن لیست کانالها، دکمهی منو در بالای صفحه را بزن. سپس یک کانال انتخاب یا اضافه کن.',
|
||
telemirror_refresh: 'بهروز کردن',
|
||
telemirror_refresh_warn: 'این کانال {n} ثانیه پیش بهروز شد. بهروزرسانی مکرر ممکنه به محدودیت تعداد درخواست بخوره و چند دقیقه قابل استفاده نباشه. ادامه میدی؟',
|
||
telemirror_refresh_yes: 'بهروز کن',
|
||
telemirror_voice: 'پیام صوتی',
|
||
telemirror_audio: 'فایل صوتی',
|
||
telemirror_file: 'فایل',
|
||
telemirror_poll: 'نظرسنجی',
|
||
telemirror_about: 'دربارهی این کانال',
|
||
download: 'دانلود',
|
||
copy: 'کپی',
|
||
// END telemirror
|
||
font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان',
|
||
next_fetch_info: 'زمان باقیمانده تا دریافت بعدی محتوا توسط سرور',
|
||
no_profiles: 'هنوز پروفایلی وجود ندارد', add_profile: '+ پروفایل جدید',
|
||
import_uri_label: 'وارد کردن پیوند (URI)', import_uri_ph: 'thefeed://...', import: 'وارد کردن', close: 'بستن',
|
||
invalid_uri: 'پیوند باید با thefeed:// شروع شود', uri_missing: 'پیوند فاقد دامنه یا رمز است',
|
||
import_success: 'وارد شد! پروفایل "{d}" ساخته شد.', import_error: 'خطا در وارد کردن',
|
||
new_profile: 'پروفایل جدید', edit_profile: 'ویرایش پروفایل',
|
||
nickname: 'نام مستعار', domain: 'دامنه', passphrase: 'رمز رمزنگاری',
|
||
resolvers: 'Resolvers (یک در هر خط)', query_mode: 'حالت کوئری', rate_limit: 'تعداد همزمانی دریافت بلاکها',
|
||
channels: 'کانال\u200cها', add: 'افزودن', remove: 'حذف',
|
||
scatter: 'تعداد ریزالور همزمان برای هر بلاک',
|
||
update_available: 'نسخه جدید موجود است: {v}',
|
||
latest_version: 'آخرین نسخه قابل دانلود',
|
||
save_current_list: 'لیست جدید',
|
||
info: 'توضیحات',
|
||
resolver_list_name_ph: 'نام لیست',
|
||
empty_list_scan_confirm: 'لیست «{n}» خالی است. میخواهی بانک را اسکن کنیم تا ریزالورهای فعال پیدا شوند؟',
|
||
empty_list_no_bank: 'این لیست و بانک هر دو خالی هستند. میتوانی از قسمت بانک بهصورت دستی ریزالور اضافه کنی یا از اسکنر استفاده کنی.',
|
||
scan: 'اسکن',
|
||
dont_show_again: 'دیگر نشان نده',
|
||
show_scan_prompt: 'نمایش اعلان اسکن در شروع برنامه',
|
||
startup_scan_msg: 'الان اسکن کنیم تا ریزالورهای فعال پیدا شوند؟',
|
||
startup_scan_off: 'اعلان اسکن غیرفعال شد',
|
||
add_to_list: 'افزودن به لیست',
|
||
no_lists: 'لیستی ذخیره نشده',
|
||
added_to_list: 'به «{n}» اضافه شد',
|
||
already_in_list: 'قبلاً در «{n}» وجود دارد',
|
||
add_failed: 'افزودن ناموفق بود',
|
||
share_source: 'منبع',
|
||
profile_name: 'نام پروفایل',
|
||
share_uri: 'پیوند اشتراک',
|
||
paste: 'چسباندن',
|
||
clipboard_blocked: 'دسترسی به کلیپبورد ممکن نیست',
|
||
resolver_list_switch_failed: 'تغییر لیست ممکن نشد',
|
||
resolver_list_rename_prompt: 'نام جدید برای «{n}»',
|
||
resolver_list_delete_confirm: 'حذف «{n}»؟',
|
||
resolver_list_overwrite_confirm: 'لیستی با نام «{n}» وجود دارد. جایگزین شود؟',
|
||
resolver_list_name_prompt: 'نام لیست را وارد کنید:',
|
||
resolver_list_saved: '«{n}» ذخیره شد',
|
||
manage_lists: 'مدیریت',
|
||
rename: 'تغییر نام', delete: 'حذف', overwrite: 'جایگزینی',
|
||
rename_failed: 'تغییر نام ناموفق بود',
|
||
delete_failed: 'حذف ناموفق بود',
|
||
save_failed: 'ذخیره ناموفق بود',
|
||
check_latest_version: 'بررسی نسخه جدید',
|
||
check_now: 'بررسی',
|
||
check_github: 'بررسی در گیتهاب',
|
||
checking_version: 'در حال بررسی...',
|
||
version_up_to_date: 'نسخه شما بهروز است: {v}',
|
||
version_check_failed: 'بررسی نسخه ناموفق بود',
|
||
update_check_failed: 'بررسی بهروزرسانی ناموفق بود',
|
||
update_download_hint: 'برای دانلود نسخه جدید روی دکمه زیر کلیک کنید:',
|
||
update_download_btn: 'دانلود',
|
||
update_later_btn: 'بعداً',
|
||
channel_mgmt_note: 'قابلیت مدیریت کانال نیاز به فعال سازی سمت سرور دارد. اگر توسط ادمین غیرفعال شده باشد، افزودن/حذف کار نمی\u200cکند.',
|
||
channel_mgmt_inactive: 'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
|
||
channel_placeholder: 'نام کاربری کانال',
|
||
version: 'نسخه',
|
||
edit: 'ویرایش', share: 'اشتراک\u200cگذاری', delete: 'حذف', save: 'ذخیره', cancel: 'لغو', yes: 'بله', no: 'خیر',
|
||
copied: 'کپی شد!', copy: 'کپی', active: 'فعال',
|
||
private: 'خصوصی', x_posts: 'پستهای X', x_label: 'X', no_config: 'ابتدا پروفایل را ذخیره کنید',
|
||
refreshing: 'در حال بروزرسانی...', fetching_channel: 'در حال دریافت کانال...',
|
||
msg_copied: 'پیام کپی شد!', rescan_started: 'بررسی مجدد شروع شد', no_new_messages: 'پیام جدیدی نیست',
|
||
add_manual: '✎ ساخت دستی', rescan: 'بررسی مجدد',
|
||
new_messages: 'پیام جدید', missed_messages: '{n} پیام از دست رفته یا حذف شده',
|
||
auto_update_toggle: 'بهروزرسانی خودکار این کانال',
|
||
auto_update_on: 'بهروزرسانی خودکار برای @{name} روشن شد',
|
||
auto_update_off: 'بهروزرسانی خودکار برای @{name} خاموش شد',
|
||
media_relay_fallback: 'دانلود از مسیر سریع نشد. از مسیر کند (DNS) امتحان کنیم؟ توجه: ممکن است خیلی کند باشد.',
|
||
media_slow_only: 'رله گیتهاب در دسترس نیست. دانلود از مسیر DNS خیلی کند است. ادامه میدهی؟',
|
||
media_rate_limited: 'محدودیت گیتهاب پر شد ({n} دقیقه تا ریست). از مسیر DNS ادامه میدهیم.',
|
||
media_rate_limited_short: 'محدودیت تعداد درخواست گیتهاب',
|
||
media_rate_limited_fallback: 'محدودیت گیتهاب — رفتن به DNS',
|
||
media_relay_404_fallback: 'هنوز در رله سریع نیست — رفتن به DNS',
|
||
close_confirm: 'بستن thefeed؟',
|
||
close_cancel: 'نبند',
|
||
close_background: 'بستن، اما در پسزمینه فعال بمونه',
|
||
close_kill: 'بستن و توقف کامل سرویس',
|
||
media_size_mismatch: 'سایز فایل با چیزی که سرور گفته بود نمیخونه',
|
||
clear_cache: 'پاک کردن کش', clear_cache_btn: '🗑 پاک کردن کش', cache_cleared: 'کش پاک شد!',
|
||
saved_resolvers_title: 'شروع سریع',
|
||
saved_resolvers_msg: 'آخرین اسکن ({t}) نتیجه داد: {n} سرور DNS سالم پیدا شد. همینها را استفاده کنیم یا دوباره اسکن کنیم؟',
|
||
saved_resolvers_use: 'استفاده کن (بدون اسکن)',
|
||
saved_resolvers_rescan: 'اسکن مجدد',
|
||
saved_resolvers_skip: 'بعداً',
|
||
saved_resolvers_applied: 'سرورهای DNS ذخیرهشده اعمال شدند',
|
||
minutes_ago: 'دقیقه پیش',
|
||
hours_ago: 'ساعت پیش',
|
||
scanner_title: '\uD83D\uDD0D اسکنر ریزالور',
|
||
scanner_about: 'این ابزار بازههای IP را برای پیدا کردن سرورهای DNS که به سرور thefeed شما دسترسی دارند اسکن میکند. CIDR (مثل 192.168.1.0/24) یا IP وارد کنید، پروفایل را انتخاب کنید و اسکن را شروع کنید. برنامه یک کوئری آزمایشی کوچک به هر IP میفرستد. اگر جواب درست بدهد، یک ریزالور کارآمد است. میتوانید «گسترش /24» را فعال کنید — وقتی یک IP کارآمد پیدا شد، آیپیهای نزدیک هم بررسی میشوند. نتایج زمان پاسخدهی را نشان میدهند تا بتوانید سریعترینها را انتخاب کنید. میتوانید هر زمان اسکن را متوقف، مکث یا ادامه دهید.',
|
||
scanner_targets: 'آیپی یا CIDR (هر خط یکی)',
|
||
scanner_profile: 'پروفایل',
|
||
scanner_rate_limit: 'همزمانی',
|
||
scanner_timeout: 'تایماوت (ثانیه)',
|
||
scanner_max_ips: 'حداکثر آیپی (0=همه)',
|
||
scanner_expand_subnet: 'گسترش /24 — وقتی ریزالور کارآمد پیدا شد آیپیهای نزدیک هم بررسی شوند',
|
||
scanner_status: 'وضعیت',
|
||
scanner_found: 'پیدا شده',
|
||
scanner_latency: 'زمان پاسخ',
|
||
scanner_append: 'افزودن به بانک و لیست فعال',
|
||
scanner_start: 'شروع اسکن',
|
||
scanner_stop: 'توقف',
|
||
scanner_pause: 'مکث',
|
||
scanner_resume: 'ادامه',
|
||
scanner_find_resolvers: '\uD83D\uDD0D پیدا کردن ریزالور',
|
||
scanner_running: 'در حال اسکن',
|
||
scanner_paused: 'مکث شده',
|
||
scanner_done: 'تمام شد',
|
||
scanner_idle: 'آماده',
|
||
scanner_applied: 'ریزالورها اعمال شدند',
|
||
scanner_no_results: 'هیچ ریزالور کارآمدی پیدا نشد',
|
||
scanner_already_running: 'اسکنر در حال اجراست',
|
||
scanner_about_short: 'بازههای IP را اسکن کنید تا ریزالورهای DNS سازگار با سرور شما پیدا شوند.',
|
||
scanner_read_more: 'بیشتر بخوانید...',
|
||
scanner_read_less: 'بستن',
|
||
scanner_load_presets: 'بارگذاری لیست ایران',
|
||
scanner_preset_active: 'ریزالورهای ایران بارگذاری شد',
|
||
scanner_from_input: 'از ورودی',
|
||
scanner_from_preset: 'از پیشفرض',
|
||
scanner_new_scan: 'اسکن جدید',
|
||
scanner_advanced: 'تنظیمات پیشرفته',
|
||
scanner_copy_all: 'کپی همه',
|
||
theme: 'پوسته',
|
||
theme_dark: 'تاریک',
|
||
theme_light: 'روشن',
|
||
rescan_prompt_title: 'بررسی ریزالورها',
|
||
rescan_prompt_msg: '{n} ریزالور سالم از اسکن قبلی موجود است. بدون بررسی مجدد ادامه دهیم؟',
|
||
rescan_prompt_skip: 'ادامه بدون اسکن',
|
||
rescan_prompt_yes: 'بررسی مجدد',
|
||
search_messages: 'جستجو',
|
||
search_no_results: 'نتیجهای یافت نشد',
|
||
sidebar_scanner: 'اسکنر',
|
||
sidebar_resolvers: 'ریزالورها',
|
||
sidebar_log: 'لاگ',
|
||
export_title: 'کپی پیامها',
|
||
export_messages: 'کپی پیام',
|
||
export_count: 'تعداد پیام',
|
||
export_copy: 'کپی در کلیپبورد',
|
||
export_copied: 'پیامها کپی شدند!',
|
||
export_no_messages: 'پیامی برای خروجی وجود ندارد',
|
||
show_resolvers: 'ریزالورهای فعال',
|
||
show_resolvers_btn: 'نمایش',
|
||
resolvers_title: 'بانک ریزالور',
|
||
no_active_resolvers: 'ریزالور فعالی وجود ندارد',
|
||
resolver_speed: 'سرعت',
|
||
resolver_score: 'امتیاز',
|
||
reset_scoreboard: 'ریست امتیازها',
|
||
resolver_bank_note: 'ریزالورها در بانک مشترک مدیریت میشوند.',
|
||
open_resolver_bank: 'باز کردن بانک ریزالور',
|
||
resolver_bank_info: 'ریزالورها سرورهای DNS هستند که برای اتصال به thefeed و دریافت اطلاعات استفاده میشوند. از اسکنر برای یافتن ریزالورهای جدید استفاده کنید یا آنها را دستی اضافه کنید.',
|
||
resolver_tab_active: 'فعال',
|
||
resolver_tab_bank: 'بانک',
|
||
cleanup_title: 'حذف ریزالورهای ضعیف',
|
||
min_score: 'حداقل امتیاز:',
|
||
remove_bad_resolvers: 'حذف ریزالورهای ضعیف',
|
||
would_be_removed: 'حذف خواهد شد',
|
||
would_remain: 'باقی میماند',
|
||
removed: 'حذف شد',
|
||
remaining: 'باقیمانده',
|
||
added: 'اضافه شد',
|
||
add_resolvers: 'افزودن ریزالور',
|
||
select_resolvers_export: 'ریزالورها برای اشتراکگذاری:',
|
||
select_all: 'همه',
|
||
select_none: 'هیچکدام',
|
||
import_add_resolvers: 'آیا {n} ریزالور وارد شده به بانک اضافه شود؟',
|
||
import_add_resolvers_large: 'بانک شما {c} ریزالور دارد. آیا {n} ریزالور جدید اضافه شود؟',
|
||
bg_image: 'تصویر پسزمینه',
|
||
apply: 'اعمال',
|
||
clear_bg: 'پاک کردن',
|
||
dns_timeout: 'تایماوت DNS (ثانیه)',
|
||
auto_scan: 'بررسی خودکار ریزالورها (هر ساعت)',
|
||
scanner_clear_targets: '\uD83D\uDDD1 پاک کردن',
|
||
app_password: 'رمز عبور برنامه',
|
||
password_new_ph: 'رمز عبور جدید',
|
||
password_confirm_ph: 'تکرار رمز عبور',
|
||
password_current_ph: 'رمز عبور فعلی',
|
||
password_set: 'تنظیم رمز',
|
||
password_change_remove: 'تغییر / حذف',
|
||
password_change: 'تغییر رمز',
|
||
password_remove: 'حذف رمز',
|
||
password_remove_confirm: 'آیا مطمئن هستید که میخواهید رمز عبور را حذف کنید؟',
|
||
password_enter_new: 'رمز عبور جدید را وارد کنید',
|
||
password_active: 'رمز عبور فعال است',
|
||
password_set_ok: 'رمز عبور تنظیم شد!',
|
||
password_removed: 'رمز عبور حذف شد!',
|
||
password_mismatch: 'رمزها مطابقت ندارند',
|
||
password_wrong: 'رمز عبور اشتباه است',
|
||
password_empty: 'رمز عبور نمی\u200cتواند خالی باشد',
|
||
poll_placeholder: 'نظرسنجی (برای مشاهده تلگرام را باز کنید)',
|
||
},
|
||
en: {
|
||
search: 'Search...', settings: 'Settings', profiles: 'Profiles',
|
||
no_channels: 'No channels yet', loading: 'Loading...', no_messages: 'No messages in this channel',
|
||
no_channels_hint: 'Working in the background — tap',
|
||
no_channels_hint2: 'to see what\'s happening',
|
||
scanning_resolvers: 'Scanning resolvers',
|
||
server_fetch_wait: 'Server fetching fresh data from Telegram',
|
||
no_messages_hint: 'The app is trying to fetch messages. Please wait a moment...',
|
||
download: 'Download', media_too_large: 'Too large for server cache', media_failed: 'Download failed',
|
||
downloading: 'Downloading...', media_open: 'Open', media_play: 'Play', media_save: 'Save', media_share: 'Share', close: 'Close',
|
||
queued: 'Queued', media_hash_mismatch: 'Content hash does not match the message',
|
||
blocks_label: 'chunks',
|
||
media_blocked_title: 'Not available for download',
|
||
media_blocked_too_large: 'This file ({size}) is larger than the server\'s cache limit ({limit}).',
|
||
media_blocked_unavailable: 'The server didn\'t cache this file. It may exceed the {limit} cache limit, or the upstream fetch failed.',
|
||
media_blocked_generic: 'The server didn\'t cache this file for download.',
|
||
ok: 'OK',
|
||
cancel_media_msg: 'Cancel this download?', dismiss: 'Dismiss',
|
||
write_message: 'Write a message...', configure_server: 'Configure a server to start reading',
|
||
set_up: 'Set Up', switching: 'Switching profile...', select_channel_hint: 'Pick a channel to view its messages',
|
||
// BEGIN telemirror
|
||
telemirror_btn: 'Browse channels',
|
||
telemirror_btn_title: 'Browse channels',
|
||
telemirror_title: 'Browse channels',
|
||
telemirror_disclaimer: 'Browsing custom channels here only works while Google services are reachable. If it stops working for you, use thefeed\'s main features — those are filter-resistant.',
|
||
telemirror_add_ph: 'e.g. @VahidOnline',
|
||
telemirror_loading: 'Loading...',
|
||
telemirror_load_failed: 'Failed to load channel.',
|
||
telemirror_no_posts: 'No posts found.',
|
||
telemirror_pick_channel: 'Pick a channel',
|
||
telemirror_invalid_user: 'Invalid username',
|
||
telemirror_remove_pinned: 'Pinned channels cannot be removed',
|
||
telemirror_views: 'views',
|
||
telemirror_edited: 'edited',
|
||
telemirror_first_hint: 'Pick a channel from the list, or add a new one with the input above.',
|
||
telemirror_first_hint_mobile: 'Tap the menu button at the top to open the channel list, then pick or add a channel.',
|
||
telemirror_refresh: 'Refresh',
|
||
telemirror_refresh_warn: 'This channel was refreshed {n} sec ago. Refreshing too often can hit a rate limit and stop working for a few minutes. Refresh anyway?',
|
||
telemirror_refresh_yes: 'Refresh',
|
||
telemirror_voice: 'Voice message',
|
||
telemirror_audio: 'Audio',
|
||
telemirror_file: 'File',
|
||
telemirror_poll: 'Poll',
|
||
telemirror_about: 'About this channel',
|
||
download: 'Download',
|
||
copy: 'Copy',
|
||
// END telemirror
|
||
font_size: 'Font Size', debug_mode: 'Debug mode', language: 'Language',
|
||
next_fetch_info: 'Time until the server next fetches fresh channel content',
|
||
no_profiles: 'No profiles yet', add_profile: '+ Add Profile',
|
||
import_uri_label: 'Import URI', import_uri_ph: 'thefeed://...', import: 'Import', close: 'Close',
|
||
invalid_uri: 'URI must start with thefeed://', uri_missing: 'URI missing domain or passphrase',
|
||
import_success: 'Imported! Profile "{d}" created.', import_error: 'Import error',
|
||
new_profile: 'New Profile', edit_profile: 'Edit Profile',
|
||
nickname: 'Nickname', domain: 'Domain', passphrase: 'Passphrase',
|
||
resolvers: 'Resolvers (one per line)', query_mode: 'Query Mode', rate_limit: 'Concurrent block fetches',
|
||
channels: 'Channels', add: 'Add', remove: 'Remove',
|
||
scatter: 'Parallel resolvers per block',
|
||
update_available: 'New version available: {v}',
|
||
latest_version: 'Latest Version',
|
||
save_current_list: 'New list',
|
||
info: 'Info',
|
||
resolver_list_name_ph: 'List name',
|
||
empty_list_scan_confirm: 'List "{n}" is empty. Scan the bank to find working resolvers?',
|
||
empty_list_no_bank: 'This list and the bank are both empty. Open the Bank tab to add resolvers manually, or run the resolver scanner.',
|
||
scan: 'Scan',
|
||
dont_show_again: "Don't show again",
|
||
show_scan_prompt: 'Show scan prompt on startup',
|
||
startup_scan_msg: 'Run a scan now to find working resolvers?',
|
||
startup_scan_off: 'Startup scan prompt disabled',
|
||
add_to_list: 'Add to list',
|
||
no_lists: 'No saved lists',
|
||
added_to_list: 'Added to "{n}"',
|
||
already_in_list: 'Already in "{n}"',
|
||
add_failed: 'Add failed',
|
||
share_source: 'Source',
|
||
profile_name: 'Profile name',
|
||
share_uri: 'Share URI',
|
||
paste: 'Paste',
|
||
clipboard_blocked: 'Clipboard access blocked',
|
||
resolver_list_switch_failed: 'Could not switch list',
|
||
resolver_list_rename_prompt: 'New name for "{n}"',
|
||
resolver_list_delete_confirm: 'Delete "{n}"?',
|
||
resolver_list_overwrite_confirm: 'A list named "{n}" already exists. Overwrite?',
|
||
resolver_list_name_prompt: 'Name this list:',
|
||
resolver_list_saved: 'Saved "{n}"',
|
||
manage_lists: 'Manage',
|
||
rename: 'Rename', delete: 'Delete', overwrite: 'Overwrite',
|
||
rename_failed: 'Rename failed',
|
||
delete_failed: 'Delete failed',
|
||
save_failed: 'Save failed',
|
||
check_latest_version: 'Check for Updates',
|
||
check_now: 'Check Now',
|
||
check_github: 'Check on GitHub',
|
||
checking_version: 'Checking...',
|
||
version_up_to_date: 'You are up to date: {v}',
|
||
version_check_failed: 'Version check failed',
|
||
update_check_failed: 'Update check failed',
|
||
update_download_hint: 'Click the button below to download the new version:',
|
||
update_download_btn: 'Download',
|
||
update_later_btn: 'Later',
|
||
channel_mgmt_note: 'Channel management requires server-side support. If disabled by the server admin, adding/removing channels will not work.',
|
||
channel_mgmt_inactive: 'Switch to this profile first to manage its channels.',
|
||
channel_placeholder: 'channel_username',
|
||
version: 'Version',
|
||
edit: 'Edit', share: 'Share', delete: 'Delete', save: 'Save', cancel: 'Cancel', yes: 'Yes', no: 'No',
|
||
copied: 'URI copied!', copy: 'Copy', active: 'Active',
|
||
private: 'Private', x_posts: 'X Posts', x_label: 'X', no_config: 'Save a profile first',
|
||
refreshing: 'Refreshing...', fetching_channel: 'Fetching channel...',
|
||
msg_copied: 'Message copied!', rescan_started: 'Rescan started', no_new_messages: 'No new messages',
|
||
add_manual: '✎ Create Manually', rescan: 'Rescan',
|
||
new_messages: 'New messages', missed_messages: '{n} messages missed or deleted',
|
||
auto_update_toggle: 'Auto-update this channel',
|
||
auto_update_on: 'Auto-update is now ON for @{name}',
|
||
auto_update_off: 'Auto-update is now OFF for @{name}',
|
||
media_relay_fallback: 'Fast relay failed. Try the slow DNS path? Note: this can be very slow.',
|
||
media_slow_only: 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
|
||
media_rate_limited: 'GitHub rate limit hit ({n} min until reset). Falling back to slow DNS path.',
|
||
media_rate_limited_short: 'GitHub rate limit',
|
||
media_rate_limited_fallback: 'Rate limit hit — switching to DNS',
|
||
media_relay_404_fallback: 'Not in fast relay yet — switching to DNS',
|
||
close_confirm: 'Close thefeed?',
|
||
close_cancel: "Don't close",
|
||
close_background: 'Close UI but keep running in background',
|
||
close_kill: 'Close and stop the service',
|
||
media_size_mismatch: 'Downloaded size doesn\'t match the manifest',
|
||
clear_cache: 'Clear Cache', clear_cache_btn: '🗑 Clear Cache', cache_cleared: 'Cache cleared!',
|
||
saved_resolvers_title: 'Quick Start',
|
||
saved_resolvers_msg: 'Last scan ({t}) found {n} healthy DNS servers. Use them now (no scan needed), or scan again to re-verify.',
|
||
saved_resolvers_use: 'Use Now (skip scan)',
|
||
saved_resolvers_rescan: 'Scan Again',
|
||
saved_resolvers_skip: 'Later',
|
||
saved_resolvers_applied: 'Saved DNS servers applied!',
|
||
minutes_ago: 'min ago',
|
||
hours_ago: 'hr ago',
|
||
scanner_title: '\uD83D\uDD0D Resolver Scanner',
|
||
scanner_about: 'This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.',
|
||
scanner_targets: 'IPs or CIDRs (one per line)',
|
||
scanner_profile: 'Profile',
|
||
scanner_rate_limit: 'Concurrency',
|
||
scanner_timeout: 'Timeout (s)',
|
||
scanner_max_ips: 'Max IPs (0=all)',
|
||
scanner_expand_subnet: 'Expand /24 — scan nearby IPs when a working resolver is found',
|
||
scanner_status: 'Status',
|
||
scanner_found: 'found',
|
||
scanner_latency: 'Latency',
|
||
scanner_append: 'Add to bank & active list',
|
||
scanner_start: 'Start Scan',
|
||
scanner_stop: 'Stop',
|
||
scanner_pause: 'Pause',
|
||
scanner_resume: 'Resume',
|
||
scanner_find_resolvers: '\uD83D\uDD0D Find Resolvers',
|
||
scanner_running: 'Running',
|
||
scanner_paused: 'Paused',
|
||
scanner_done: 'Done',
|
||
scanner_idle: 'Ready',
|
||
scanner_applied: 'Resolvers applied',
|
||
scanner_no_results: 'No working resolvers found',
|
||
scanner_already_running: 'Scanner is already running',
|
||
scanner_about_short: 'Scan IP ranges to find DNS resolvers that work with your server.',
|
||
scanner_read_more: 'Read more...',
|
||
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',
|
||
theme: 'Theme',
|
||
theme_dark: 'Dark',
|
||
theme_light: 'Light',
|
||
rescan_prompt_title: 'Resolver Check',
|
||
rescan_prompt_msg: '{n} healthy resolvers from previous scan. Continue without rescanning?',
|
||
rescan_prompt_skip: 'Skip Rescan',
|
||
rescan_prompt_yes: 'Rescan',
|
||
search_messages: 'Search',
|
||
search_no_results: 'No results',
|
||
sidebar_scanner: 'Scanner',
|
||
sidebar_resolvers: 'Resolvers',
|
||
sidebar_log: 'Log',
|
||
export_title: 'Copy Messages',
|
||
export_messages: 'Copy',
|
||
export_count: 'Number of messages',
|
||
export_copy: 'Copy to Clipboard',
|
||
export_copied: 'Messages copied!',
|
||
export_no_messages: 'No messages to export',
|
||
show_resolvers: 'Working Resolvers',
|
||
show_resolvers_btn: 'Show',
|
||
resolvers_title: 'Resolver Bank',
|
||
no_active_resolvers: 'No active resolvers',
|
||
resolver_speed: 'Speed',
|
||
resolver_score: 'Score',
|
||
reset_scoreboard: 'Reset Scores',
|
||
resolver_bank_note: 'Resolvers are managed in the shared Resolver Bank.',
|
||
open_resolver_bank: 'Open Resolver Bank',
|
||
resolver_bank_info: 'Resolvers are DNS servers used to connect to thefeed and fetch data. Use the Scanner to find new resolvers, or add them manually below.',
|
||
resolver_tab_active: 'Active',
|
||
resolver_tab_bank: 'Bank',
|
||
cleanup_title: 'Remove Bad Resolvers',
|
||
min_score: 'Min score:',
|
||
remove_bad_resolvers: 'Remove Bad Resolvers',
|
||
would_be_removed: 'would be removed',
|
||
would_remain: 'would remain',
|
||
removed: 'Removed',
|
||
remaining: 'remaining',
|
||
added: 'Added',
|
||
add_resolvers: 'Add Resolvers',
|
||
select_resolvers_export: 'Resolvers to include in share link:',
|
||
select_all: 'All',
|
||
select_none: 'None',
|
||
import_add_resolvers: 'Add {n} imported resolvers to your bank?',
|
||
import_add_resolvers_large: 'Your bank has {c} resolvers. Add {n} new resolvers?',
|
||
bg_image: 'Background Image',
|
||
apply: 'Apply',
|
||
clear_bg: 'Clear',
|
||
dns_timeout: 'DNS Query Timeout (s)',
|
||
auto_scan: 'Automatic hourly resolver check',
|
||
scanner_clear_targets: '\uD83D\uDDD1 Clear',
|
||
app_password: 'App Password',
|
||
password_new_ph: 'New password',
|
||
password_confirm_ph: 'Confirm password',
|
||
password_current_ph: 'Current password',
|
||
password_set: 'Set Password',
|
||
password_change_remove: 'Change / Remove',
|
||
password_change: 'Change',
|
||
password_remove: 'Remove',
|
||
password_remove_confirm: 'Are you sure you want to remove the password?',
|
||
password_enter_new: 'Enter your new password',
|
||
password_active: 'Password is active',
|
||
password_set_ok: 'Password set!',
|
||
password_removed: 'Password removed!',
|
||
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';
|
||
function t(k) { return (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k }
|
||
function applyLang() {
|
||
var isRtl = lang === 'fa';
|
||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||
document.documentElement.lang = lang;
|
||
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.dataset.i18n) });
|
||
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.placeholder = t(el.dataset.i18nPh) });
|
||
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = t(el.dataset.i18nTitle) });
|
||
document.getElementById('langFa').classList.toggle('active-lang', lang === 'fa');
|
||
document.getElementById('langEn').classList.toggle('active-lang', lang === 'en');
|
||
document.getElementById('sendInput').style.direction = isRtl ? 'rtl' : 'ltr';
|
||
applyThemeButtons();
|
||
// Re-render dynamic content
|
||
if (channels.length > 0) renderChannels();
|
||
}
|
||
function setLang(l) {
|
||
lang = l;
|
||
localStorage.setItem('thefeed_lang', l);
|
||
applyLang();
|
||
if (typeof Android !== 'undefined') try { Android.setLang(l) } catch (e) { }
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lang: l }) }).catch(function () { });
|
||
}
|
||
|
||
// ===== STATE =====
|
||
var selectedChannel = 0, channels = [], eventSource = null, autoRefreshTimer = null, telegramLoggedIn = false, logVisible = false;
|
||
// previousMsgIDs is kept for the "no_new_messages" toast on refresh.
|
||
// previousContentHashes drives the channel-list NEW badge — robust across
|
||
// both Telegram (monotonic IDs) and X accounts (CRC32-hashed snowflake
|
||
// IDs that aren't ordered). Both maps are persisted to localStorage so
|
||
// they survive page reload and the user keeps seeing badges.
|
||
var serverNextFetch = 0, nextFetchInterval = null, previousMsgIDs = loadSeenMap('thefeed_seen_ids'), previousContentHashes = loadSeenMap('thefeed_seen_hashes'), currentMsgTexts = [];
|
||
function loadSeenMap(storageKey) {
|
||
try {
|
||
var raw = localStorage.getItem(storageKey);
|
||
if (!raw) return {};
|
||
var parsed = JSON.parse(raw);
|
||
return (parsed && typeof parsed === 'object') ? parsed : {};
|
||
} catch (e) { return {}; }
|
||
}
|
||
function saveSeenMap(storageKey, m) {
|
||
try { localStorage.setItem(storageKey, JSON.stringify(m)); } catch (e) { }
|
||
}
|
||
function rememberSeen(name, lastID, contentHash) {
|
||
if (!name) return;
|
||
previousMsgIDs[name] = lastID || 0;
|
||
previousContentHashes[name] = contentHash || 0;
|
||
saveSeenMap('thefeed_seen_ids', previousMsgIDs);
|
||
saveSeenMap('thefeed_seen_hashes', previousContentHashes);
|
||
}
|
||
var appVersion = '', latestVersion = '';
|
||
var profiles = null, activeProfileId = '', editingProfileId = null, resolverScanHint = '', resolverScanHealthy = 0, resolverScanDone = 0, resolverScanTotal = 0;
|
||
var currentMaxMsgID = 0;
|
||
var currentMaxTimestamp = 0;
|
||
var newMsgScrollDone = false;
|
||
// Per-channel state for the "new messages" separator. The separator is
|
||
// kept visible across re-renders for NEW_MSG_STICKY_MS by deferring the
|
||
// lastSeen-timestamp commit, so users actually have time to notice the
|
||
// new content before it gets marked seen.
|
||
var NEW_MSG_STICKY_MS = 10000; // how long the "new messages" tag stays
|
||
var newMsgSepLastSeen = {}; // ch name → lastSeenTs the current sep represents
|
||
var newMsgSepCommitTimer = {}; // ch name → setTimeout handle for deferred commit
|
||
var refreshingChannels = {};
|
||
|
||
// ===== MOBILE NAV =====
|
||
var mobileQuery = window.matchMedia('(max-width: 768px)');
|
||
var chatIsOpen = false;
|
||
// Desktop: chat is always laid out alongside the sidebar.
|
||
// Mobile: chat is only visible when .chat-open is set.
|
||
function _chatPanelVisible() {
|
||
return !mobileQuery.matches || document.getElementById('app').classList.contains('chat-open');
|
||
}
|
||
|
||
function openChat() {
|
||
chatIsOpen = true;
|
||
if (mobileQuery.matches) {
|
||
document.getElementById('app').classList.add('chat-open');
|
||
history.pushState({ view: 'chat' }, '');
|
||
}
|
||
}
|
||
function openSidebar() {
|
||
chatIsOpen = false;
|
||
document.getElementById('app').classList.remove('chat-open');
|
||
}
|
||
window.addEventListener('popstate', function () {
|
||
if (mobileQuery.matches && document.getElementById('app').classList.contains('chat-open')) {
|
||
openSidebar();
|
||
}
|
||
});
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden && mobileQuery.matches && chatIsOpen) {
|
||
document.getElementById('app').classList.add('chat-open');
|
||
}
|
||
});
|
||
function filterChannels() {
|
||
var q = document.getElementById('channelSearch').value.toLowerCase();
|
||
document.querySelectorAll('.ch-item').forEach(function (el) { el.style.display = el.dataset.name.toLowerCase().includes(q) ? 'flex' : 'none' });
|
||
}
|
||
|
||
// ===== INIT =====
|
||
async function init() {
|
||
loadTheme();
|
||
applyLang();
|
||
await loadFontSize();
|
||
loadBgImage();
|
||
connectSSE();
|
||
refreshResolversBadge();
|
||
// Quietly ask GitHub for the latest published client version. Runs in
|
||
// the background so a slow github.com response can't delay startup —
|
||
// if there's an update, the dialog shows up a few seconds later.
|
||
checkGitHubUpdate(false).catch(function () { });
|
||
try {
|
||
var r = await fetch('/api/status'); var st = await r.json();
|
||
await loadProfiles();
|
||
if (!st.configured) { openProfiles(); return }
|
||
checkAndShowSavedResolversPrompt(st);
|
||
telegramLoggedIn = !!st.telegramLoggedIn;
|
||
serverNextFetch = st.nextFetch || 0;
|
||
latestVersion = st.latestVersion || '';
|
||
renderLatestVersion();
|
||
updateNextFetchDisplay();
|
||
await loadChannels();
|
||
// Land on the channel list; don't auto-open the first channel.
|
||
if (!channels || channels.length === 0) {
|
||
showInitProgress(); await doRefresh();
|
||
} else {
|
||
// Mobile: make sure the sidebar is visible so the user can pick.
|
||
openSidebar();
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + (t('select_channel_hint') || '') + '</p></div>';
|
||
}
|
||
startAutoRefresh();
|
||
} catch (e) { }
|
||
}
|
||
|
||
// ===== FONT SIZE =====
|
||
async function loadFontSize() {
|
||
try {
|
||
var r = await fetch('/api/settings'); var s = await r.json();
|
||
if (s.fontSize >= 11 && s.fontSize <= 22) {
|
||
document.documentElement.style.setProperty('--font-size', s.fontSize + 'px');
|
||
document.getElementById('fontSizeSlider').value = s.fontSize;
|
||
document.getElementById('fontSizeVal').textContent = s.fontSize;
|
||
}
|
||
if (s.debug) document.getElementById('cfgDebug').checked = true;
|
||
if (s.theme && (s.theme === 'dark' || s.theme === 'light')) {
|
||
localStorage.setItem('thefeed_theme', s.theme);
|
||
document.documentElement.setAttribute('data-theme', s.theme);
|
||
applyThemeButtons();
|
||
}
|
||
if (s.lang && (s.lang === 'fa' || s.lang === 'en')) {
|
||
lang = s.lang;
|
||
localStorage.setItem('thefeed_lang', s.lang);
|
||
applyLang();
|
||
}
|
||
if (s.version) { appVersion = s.version; renderAppVersion(s.version, s.commit); }
|
||
// Sync the server-persisted "don't show scan prompt" flag
|
||
// into localStorage. Android picks a new 127.0.0.1 port on
|
||
// each launch, so localStorage alone wouldn't survive
|
||
// restart — the server-side flag is the source of truth.
|
||
if (s.scanPromptOff === true) {
|
||
localStorage.setItem('thefeed_scan_prompt_off', '1');
|
||
} else if (s.scanPromptOff === false) {
|
||
localStorage.removeItem('thefeed_scan_prompt_off');
|
||
}
|
||
renderLatestVersion();
|
||
} catch (e) { }
|
||
}
|
||
|
||
function renderAppVersion(v, commit) {
|
||
var vEl = document.getElementById('appVersionEl');
|
||
if (!vEl) return;
|
||
if (!v) { vEl.textContent = '-'; return; }
|
||
vEl.textContent = v + (commit && commit !== 'unknown' ? ' (' + commit.slice(0, 7) + ')' : '');
|
||
}
|
||
|
||
function renderLatestVersion() {
|
||
var vEl = document.getElementById('latestVersionEl');
|
||
if (vEl) vEl.textContent = latestVersion || '-';
|
||
}
|
||
|
||
function normalizeVersion(v) {
|
||
if (!v) return '';
|
||
v = String(v).trim().replace(/^v/i, '');
|
||
return v;
|
||
}
|
||
|
||
function compareSemver(a, b) {
|
||
a = normalizeVersion(a); b = normalizeVersion(b);
|
||
if (!a || !b || a === 'dev' || b === 'dev') return 0;
|
||
var as = a.split('.'); var bs = b.split('.');
|
||
var n = Math.max(as.length, bs.length);
|
||
for (var i = 0; i < n; i++) {
|
||
var ai = parseInt(as[i] || '0', 10); if (isNaN(ai)) ai = 0;
|
||
var bi = parseInt(bs[i] || '0', 10); if (isNaN(bi)) bi = 0;
|
||
if (ai > bi) return 1;
|
||
if (ai < bi) return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function maybeWarnNewVersion() {
|
||
if (!latestVersion || !appVersion) return;
|
||
if (compareSemver(latestVersion, appVersion) <= 0) return;
|
||
var seenKey = 'thefeed_seen_update_' + normalizeVersion(latestVersion);
|
||
if (localStorage.getItem(seenKey) === '1') return;
|
||
localStorage.setItem(seenKey, '1');
|
||
showToast(t('update_available').replace('{v}', latestVersion));
|
||
addLogLine('Warning: ' + t('update_available').replace('{v}', latestVersion));
|
||
}
|
||
function previewFontSize(v) { document.documentElement.style.setProperty('--font-size', v + 'px'); document.getElementById('fontSizeVal').textContent = v }
|
||
|
||
// ===== THEME =====
|
||
function loadTheme() {
|
||
var t = localStorage.getItem('thefeed_theme') || 'dark';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
}
|
||
function setTheme(t) {
|
||
localStorage.setItem('thefeed_theme', t);
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
applyThemeButtons();
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: t }) }).catch(function () { });
|
||
}
|
||
function applyThemeButtons() {
|
||
var cur = localStorage.getItem('thefeed_theme') || 'dark';
|
||
var d = document.getElementById('themeDark');
|
||
var l = document.getElementById('themeLight');
|
||
if (d) d.classList.toggle('active-theme', cur === 'dark');
|
||
if (l) l.classList.toggle('active-theme', cur === 'light');
|
||
}
|
||
|
||
// ===== LAST SEEN MESSAGES =====
|
||
function channelName(num) {
|
||
var ch = channels[num - 1];
|
||
return (ch && (ch.Name || ch.name)) || '';
|
||
}
|
||
function getLastSeenTimestamp(name) {
|
||
if (!name) return 0;
|
||
try { return parseInt(localStorage.getItem('thefeed_seen_ts_' + name)) || 0 } catch (e) { return 0 }
|
||
}
|
||
function setLastSeenTimestamp(name, ts) {
|
||
if (!name) return;
|
||
try { localStorage.setItem('thefeed_seen_ts_' + name, ts) } catch (e) { }
|
||
}
|
||
|
||
// ===== RESCAN PROMPT =====
|
||
// Resolves true → skip rescan. Honors scanPromptOff.
|
||
function askRescan(count) {
|
||
return new Promise(function (resolve) {
|
||
if (localStorage.getItem('thefeed_scan_prompt_off') === '1') { resolve(true); return; }
|
||
if (!count || count <= 0) { resolve(true); return; }
|
||
var msg = t('rescan_prompt_msg').replace('{n}', count);
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay active';
|
||
overlay.innerHTML =
|
||
'<div class="modal" style="max-width:380px">'
|
||
+ '<h2 style="margin-top:0">' + esc(t('rescan_prompt_title')) + '</h2>'
|
||
+ '<p style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">' + esc(msg) + '</p>'
|
||
+ '<div style="display:flex;justify-content:flex-end;margin-bottom:10px">'
|
||
+ '<button class="btn btn-flat" id="rescanPromptNever" style="font-size:11px;padding:4px 10px;color:var(--text-dim)">' + esc(t('dont_show_again')) + '</button>'
|
||
+ '</div>'
|
||
+ '<div class="modal-actions">'
|
||
+ '<button class="btn btn-outline" id="rescanPromptYes">' + esc(t('rescan_prompt_yes')) + '</button>'
|
||
+ '<button class="btn btn-primary" id="rescanPromptSkip">' + esc(t('rescan_prompt_skip')) + '</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
var done = function (skip) {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
resolve(skip);
|
||
};
|
||
document.getElementById('rescanPromptSkip').onclick = function () { done(true) };
|
||
document.getElementById('rescanPromptYes').onclick = function () { done(false) };
|
||
document.getElementById('rescanPromptNever').onclick = function () {
|
||
localStorage.setItem('thefeed_scan_prompt_off', '1');
|
||
try {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scanPromptOff: true })
|
||
});
|
||
} catch (e) { }
|
||
done(true);
|
||
};
|
||
});
|
||
}
|
||
function showRescanPrompt(count) { return askRescan(count); } // legacy alias
|
||
function showConfirmDialog(msg, yesText, noText) {
|
||
return new Promise(function (resolve) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay active';
|
||
overlay.innerHTML = '<div class="modal" style="max-width:380px"><p style="font-size:13px;color:var(--text);margin-bottom:16px;line-height:1.6">' + esc(msg) + '</p><div class="modal-actions"><button class="btn btn-flat" id="confirmNo">' + esc(noText || t('cancel')) + '</button><button class="btn btn-primary" id="confirmYes">' + esc(yesText || t('ok')) + '</button></div></div>';
|
||
document.body.appendChild(overlay);
|
||
document.getElementById('confirmNo').onclick = function () { document.body.removeChild(overlay); resolve(false) };
|
||
document.getElementById('confirmYes').onclick = function () { document.body.removeChild(overlay); resolve(true) };
|
||
});
|
||
}
|
||
|
||
// showInputDialog is a themed window.prompt replacement. Returns
|
||
// the trimmed input string on confirm, or null on cancel/escape.
|
||
// Uses the existing modal-overlay pattern so it inherits the
|
||
// app's theme — no more browser-native prompt with default
|
||
// chrome that ignores dark mode.
|
||
function showInputDialog(opts) {
|
||
opts = opts || {};
|
||
return new Promise(function (resolve) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay active';
|
||
var titleHtml = opts.title ? '<h2 style="margin-top:0;margin-bottom:8px;font-size:16px">' + esc(opts.title) + '</h2>' : '';
|
||
var msgHtml = opts.message ? '<p style="font-size:13px;color:var(--text-dim);margin:0 0 12px;line-height:1.6">' + esc(opts.message) + '</p>' : '';
|
||
overlay.innerHTML =
|
||
'<div class="modal" style="max-width:380px">'
|
||
+ titleHtml + msgHtml
|
||
+ '<input type="text" id="inputDialogField" maxlength="' + (opts.maxLength || 64) + '"'
|
||
+ ' value="' + esc(opts.value || '') + '"'
|
||
+ ' placeholder="' + esc(opts.placeholder || '') + '"'
|
||
+ ' autocomplete="off" spellcheck="false"'
|
||
+ ' style="width:100%;padding:9px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;font-size:13px;box-sizing:border-box;font-family:inherit">'
|
||
+ '<div class="modal-actions">'
|
||
+ '<button class="btn btn-flat" id="inputDialogCancel">' + esc(opts.cancelText || t('cancel') || 'Cancel') + '</button>'
|
||
+ '<button class="btn btn-primary" id="inputDialogOk">' + esc(opts.okText || t('ok') || 'OK') + '</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
var field = document.getElementById('inputDialogField');
|
||
var done = function (val) {
|
||
document.removeEventListener('keydown', onKey);
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
resolve(val);
|
||
};
|
||
document.getElementById('inputDialogCancel').onclick = function () { done(null); };
|
||
document.getElementById('inputDialogOk').onclick = function () {
|
||
var v = (field.value || '').trim();
|
||
done(v || null);
|
||
};
|
||
var onKey = function (e) {
|
||
if (e.key === 'Escape') { done(null); }
|
||
else if (e.key === 'Enter') {
|
||
var v = (field.value || '').trim();
|
||
done(v || null);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', onKey);
|
||
// Focus & select after the modal is in the DOM so iOS WebView
|
||
// also gets the soft keyboard up.
|
||
setTimeout(function () { try { field.focus(); field.select(); } catch (e) { } }, 30);
|
||
});
|
||
}
|
||
|
||
// showInfoDialog is the one-button cousin of showConfirmDialog: a small
|
||
// modal with a message and a single OK button. Used for explanatory
|
||
// bits like "this file is too large for the server cache".
|
||
function showInfoDialog(msg, okText) {
|
||
return new Promise(function (resolve) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay active';
|
||
overlay.innerHTML = '<div class="modal" style="max-width:380px"><p style="font-size:13px;color:var(--text);margin-bottom:16px;line-height:1.6;white-space:pre-line">' + esc(msg) + '</p><div class="modal-actions"><button class="btn btn-primary" id="infoOk">' + esc(okText || t('ok') || 'OK') + '</button></div></div>';
|
||
document.body.appendChild(overlay);
|
||
document.getElementById('infoOk').onclick = function () { document.body.removeChild(overlay); resolve(true) };
|
||
});
|
||
}
|
||
|
||
// ===== SETTINGS =====
|
||
function openSettings() {
|
||
renderLatestVersion();
|
||
applyThemeButtons();
|
||
document.getElementById('settingsModal').classList.add('active');
|
||
try { initAndroidSettings(); } catch (e) { console.error('initAndroidSettings error:', e); }
|
||
// Reflect current localStorage flag on every open — toggling it
|
||
// away in the prompt itself should be picked up next time
|
||
// settings is opened.
|
||
var promptEl = document.getElementById('cfgShowScanPrompt');
|
||
if (promptEl) promptEl.checked = localStorage.getItem('thefeed_scan_prompt_off') !== '1';
|
||
}
|
||
|
||
// Toggle the "show startup scan prompt" preference. Persists
|
||
// both client-side (localStorage for fast boot) and server-side
|
||
// (so Android keeps it across launches even when the WebView
|
||
// origin port changes).
|
||
function setShowScanPrompt(enabled) {
|
||
if (enabled) {
|
||
localStorage.removeItem('thefeed_scan_prompt_off');
|
||
// Clear the per-session "already shown" flag so the prompt
|
||
// re-appears on the next reload — without this, re-enabling
|
||
// does nothing until the browser tab is closed.
|
||
sessionStorage.removeItem('thefeed_scan_prompt_shown');
|
||
} else {
|
||
localStorage.setItem('thefeed_scan_prompt_off', '1');
|
||
}
|
||
try {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scanPromptOff: !enabled })
|
||
});
|
||
} catch (e) { }
|
||
}
|
||
function closeSettings() { document.getElementById('settingsModal').classList.remove('active') }
|
||
async function saveSettings() {
|
||
var fs = parseInt(document.getElementById('fontSizeSlider').value) || 14;
|
||
var dbg = document.getElementById('cfgDebug').checked;
|
||
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fontSize: fs, debug: dbg }) }) } catch (e) { }
|
||
closeSettings();
|
||
}
|
||
|
||
// ===== ANDROID SETTINGS =====
|
||
|
||
function initAndroidSettings() {
|
||
if (typeof Android === 'undefined') return;
|
||
document.getElementById('androidPasswordSection').style.display = '';
|
||
// Show correct password section
|
||
try {
|
||
var hasPw = Android.hasPassword();
|
||
document.getElementById('passwordSetSection').style.display = hasPw ? 'none' : '';
|
||
document.getElementById('passwordRemoveSection').style.display = hasPw ? '' : 'none';
|
||
if (!hasPw) document.getElementById('passwordRemovePrompt').style.display = 'none';
|
||
} catch (e) { }
|
||
}
|
||
|
||
function showPasswordRemovePrompt() {
|
||
document.getElementById('passwordRemovePrompt').style.display = '';
|
||
document.getElementById('appPasswordCurrent').value = '';
|
||
document.getElementById('passwordRemoveError').style.display = 'none';
|
||
}
|
||
|
||
function hidePasswordRemovePrompt() {
|
||
document.getElementById('passwordRemovePrompt').style.display = 'none';
|
||
}
|
||
|
||
function setAppPassword() {
|
||
if (typeof Android === 'undefined') return;
|
||
var pw = document.getElementById('appPasswordInput').value;
|
||
var confirm = document.getElementById('appPasswordConfirm').value;
|
||
var errEl = document.getElementById('passwordError');
|
||
errEl.style.display = 'none';
|
||
if (!pw) { errEl.textContent = t('password_empty'); errEl.style.display = ''; return }
|
||
if (pw !== confirm) { errEl.textContent = t('password_mismatch'); errEl.style.display = ''; return }
|
||
try {
|
||
var ok = Android.setPassword(pw);
|
||
if (ok) {
|
||
showToast(t('password_set_ok'));
|
||
document.getElementById('appPasswordInput').value = '';
|
||
document.getElementById('appPasswordConfirm').value = '';
|
||
document.getElementById('passwordSetSection').style.display = 'none';
|
||
document.getElementById('passwordRemoveSection').style.display = '';
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
|
||
function removeAppPassword() {
|
||
if (typeof Android === 'undefined') return;
|
||
var pw = document.getElementById('appPasswordCurrent').value;
|
||
var errEl = document.getElementById('passwordRemoveError');
|
||
errEl.style.display = 'none';
|
||
if (!pw) { errEl.textContent = t('password_empty'); errEl.style.display = ''; return }
|
||
if (!confirm(t('password_remove_confirm'))) return;
|
||
try {
|
||
var ok = Android.removePassword(pw);
|
||
if (ok) {
|
||
showToast(t('password_removed'));
|
||
document.getElementById('appPasswordCurrent').value = '';
|
||
document.getElementById('passwordSetSection').style.display = '';
|
||
document.getElementById('passwordRemoveSection').style.display = 'none';
|
||
document.getElementById('passwordRemovePrompt').style.display = 'none';
|
||
} else {
|
||
errEl.textContent = t('password_wrong');
|
||
errEl.style.display = '';
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
|
||
function changeAppPassword() {
|
||
if (typeof Android === 'undefined') return;
|
||
var pw = document.getElementById('appPasswordCurrent').value;
|
||
var errEl = document.getElementById('passwordRemoveError');
|
||
errEl.style.display = 'none';
|
||
if (!pw) { errEl.textContent = t('password_empty'); errEl.style.display = ''; return }
|
||
try {
|
||
var ok = Android.checkPassword(pw);
|
||
if (!ok) {
|
||
errEl.textContent = t('password_wrong');
|
||
errEl.style.display = '';
|
||
return;
|
||
}
|
||
// Remove old and show set section for new password
|
||
Android.removePassword(pw);
|
||
document.getElementById('appPasswordCurrent').value = '';
|
||
document.getElementById('passwordRemoveSection').style.display = 'none';
|
||
document.getElementById('passwordRemovePrompt').style.display = 'none';
|
||
document.getElementById('passwordSetSection').style.display = '';
|
||
showToast(t('password_enter_new'));
|
||
} catch (e) { }
|
||
}
|
||
|
||
// checkGitHubUpdate hits /api/update/github (which fetches the VERSION
|
||
// file from the public thefeed-files repo) and prompts the user with
|
||
// a download link tailored to their platform. `manual=true` shows a
|
||
// toast on "no update", `manual=false` stays silent.
|
||
async function checkGitHubUpdate(manual) {
|
||
try {
|
||
var r = await fetch('/api/update/github');
|
||
if (!r.ok) {
|
||
if (manual) showToast(t('update_check_failed') || 'Update check failed');
|
||
return;
|
||
}
|
||
var data = await r.json();
|
||
if (!data || !data.latest) return;
|
||
latestVersion = data.latest;
|
||
renderLatestVersion();
|
||
if (data.hasUpdate && data.downloadURL) {
|
||
if (!manual) {
|
||
// Don't nag the same user about the same version twice.
|
||
var seenKey = 'thefeed_seen_gh_update_' + normalizeVersion(data.latest);
|
||
if (localStorage.getItem(seenKey) === '1') return;
|
||
localStorage.setItem(seenKey, '1');
|
||
}
|
||
showUpdateDialog(data.latest, data.downloadURL);
|
||
} else if (manual) {
|
||
showToast((t('version_up_to_date') || 'Up to date: {v}').replace('{v}', data.latest));
|
||
}
|
||
} catch (e) {
|
||
if (manual) showToast(e.message || t('update_check_failed') || 'Update check failed');
|
||
}
|
||
}
|
||
|
||
function showUpdateDialog(newVersion, url) {
|
||
// Re-use the existing modal styling. Two buttons: download (opens
|
||
// the binary URL in a new tab / hands off to system app on Android)
|
||
// and later (just dismisses).
|
||
var msg = (t('update_available') || 'New version available: {v}').replace('{v}', newVersion);
|
||
var hint = t('update_download_hint') || 'Download the new version below.';
|
||
var dl = t('update_download_btn') || 'Download';
|
||
var later = t('update_later_btn') || 'Later';
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay active';
|
||
overlay.innerHTML = '<div class="modal" style="max-width:380px">'
|
||
+ '<h2 style="margin-top:0">' + esc(msg) + '</h2>'
|
||
+ '<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;line-height:1.6">' + esc(hint) + '</p>'
|
||
+ '<p style="font-size:11px;color:var(--text-dim);margin-bottom:16px;word-break:break-all"><code>' + esc(url) + '</code></p>'
|
||
+ '<div class="modal-actions">'
|
||
+ ' <button class="btn btn-flat" id="updateLater">' + esc(later) + '</button>'
|
||
+ ' <button class="btn btn-primary" id="updateDownload">' + esc(dl) + '</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
document.getElementById('updateLater').onclick = function () {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
};
|
||
document.getElementById('updateDownload').onclick = function () {
|
||
try {
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
} catch (e) { }
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
};
|
||
}
|
||
|
||
async function checkLatestVersion() {
|
||
var btn = document.getElementById('checkVersionBtn');
|
||
var prevText = btn ? btn.textContent : '';
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = t('checking_version');
|
||
}
|
||
try {
|
||
var r = await fetch('/api/version-check', { method: 'POST' });
|
||
var text = await r.text();
|
||
var data = {};
|
||
try { data = JSON.parse(text) } catch (e) { }
|
||
if (!r.ok) {
|
||
showToast(text || t('version_check_failed'));
|
||
return;
|
||
}
|
||
latestVersion = data.latestVersion || '';
|
||
renderLatestVersion();
|
||
if (!latestVersion) {
|
||
showToast(t('version_check_failed'));
|
||
return;
|
||
}
|
||
if (compareSemver(latestVersion, appVersion) > 0) maybeWarnNewVersion();
|
||
else showToast(t('version_up_to_date').replace('{v}', latestVersion));
|
||
} catch (e) {
|
||
showToast(e.message || t('version_check_failed'));
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.textContent = prevText || t('check_now');
|
||
}
|
||
}
|
||
}
|
||
function toggleResolverBankInfo() {
|
||
var panel = document.getElementById('resolverBankInfoPanel');
|
||
var btn = document.getElementById('resolverBankInfoBtn');
|
||
if (!panel || !btn) return;
|
||
var open = panel.hasAttribute('hidden');
|
||
if (open) panel.removeAttribute('hidden');
|
||
else panel.setAttribute('hidden', '');
|
||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}
|
||
|
||
// ===== NAMED RESOLVER LISTS =====
|
||
// The user keeps multiple named resolver lists (e.g. "Home",
|
||
// "Office", "Mobile") and switches between them with one tap. UI
|
||
// is a single tab strip at the top of the Resolver Bank modal:
|
||
// each list is a tab, "+" creates a new list, "Bank" is the master
|
||
// pool. The selected list-tab shows ⋮ that opens an inline
|
||
// rename/delete menu — no extra modals.
|
||
|
||
var rlState = { lists: [], selected: '' };
|
||
|
||
async function loadResolverLists() {
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists');
|
||
if (!r.ok) { renderResolverTabs(); return; }
|
||
rlState = await r.json();
|
||
renderResolverTabs();
|
||
} catch (e) { renderResolverTabs(); }
|
||
}
|
||
|
||
function renderResolverTabs() {
|
||
var tabs = document.getElementById('resolverTabs');
|
||
if (!tabs) return;
|
||
hideResolverTabMenu();
|
||
tabs.innerHTML = '';
|
||
var lists = (rlState && rlState.lists) || [];
|
||
lists.forEach(function (li) {
|
||
var tab = document.createElement('button');
|
||
tab.type = 'button';
|
||
tab.className = 'rtab' + (li.selected && currentResolverTab === 'active' ? ' rtab-active' : '');
|
||
tab.setAttribute('role', 'tab');
|
||
tab.title = li.name;
|
||
var label = document.createElement('span');
|
||
label.textContent = li.name;
|
||
var count = document.createElement('span');
|
||
count.className = 'rtab-count';
|
||
count.textContent = li.count || 0;
|
||
tab.appendChild(label);
|
||
tab.appendChild(count);
|
||
if (li.selected && currentResolverTab === 'active') {
|
||
var more = document.createElement('span');
|
||
more.className = 'rtab-menu-btn';
|
||
more.setAttribute('aria-label', t('manage_lists') || 'Manage');
|
||
more.innerHTML = '⋮'; // vertical ellipsis
|
||
more.onclick = function (e) {
|
||
e.stopPropagation();
|
||
toggleResolverTabMenu(this);
|
||
};
|
||
tab.appendChild(more);
|
||
}
|
||
tab.onclick = function () {
|
||
hideResolverTabMenu();
|
||
if (li.selected && currentResolverTab === 'active') return;
|
||
if (!li.selected) {
|
||
selectResolverList(li.name);
|
||
} else {
|
||
// Already selected but on the Bank view — hop back.
|
||
switchResolverTab('active');
|
||
}
|
||
};
|
||
tabs.appendChild(tab);
|
||
});
|
||
// "+" — create new list. Always present so an empty install
|
||
// still has an obvious onboarding affordance.
|
||
var add = document.createElement('button');
|
||
add.type = 'button';
|
||
add.className = 'rtab rtab-add';
|
||
add.title = t('save_current_list') || 'Save current as new list';
|
||
add.setAttribute('aria-label', t('save_current_list') || 'New list');
|
||
add.innerHTML = '+';
|
||
add.onclick = function () {
|
||
hideResolverTabMenu();
|
||
saveCurrentResolverListPrompt();
|
||
};
|
||
tabs.appendChild(add);
|
||
renderBankPill();
|
||
}
|
||
|
||
function renderBankPill() {
|
||
// The Bank chip sits on the modal header row (right edge in
|
||
// LTR / left in RTL) — different shape and location from the
|
||
// list-tabs because it points at the master pool, not a list.
|
||
var slot = document.getElementById('resolverBankSlot');
|
||
if (!slot) return;
|
||
slot.innerHTML = '';
|
||
var bank = document.createElement('button');
|
||
bank.type = 'button';
|
||
bank.id = 'resolverBankTab';
|
||
bank.className = 'rtab-bank' + (currentResolverTab === 'bank' ? ' rtab-active' : '');
|
||
bank.setAttribute('role', 'tab');
|
||
var icon = document.createElement('span');
|
||
icon.className = 'rtab-bank-icon';
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
icon.innerHTML = '💰'; // 💰 a bank pouch
|
||
var bankLabel = document.createElement('span');
|
||
bankLabel.textContent = t('resolver_tab_bank') || 'Bank';
|
||
var bankCount = document.createElement('span');
|
||
bankCount.className = 'rtab-count';
|
||
bankCount.id = 'resolverBankCount';
|
||
bank.appendChild(icon);
|
||
bank.appendChild(bankLabel);
|
||
bank.appendChild(bankCount);
|
||
bank.onclick = function () {
|
||
hideResolverTabMenu();
|
||
switchResolverTab('bank');
|
||
};
|
||
slot.appendChild(bank);
|
||
}
|
||
|
||
async function selectResolverList(name) {
|
||
if (!name) return;
|
||
// If the picked list is empty, ask the user before kicking off
|
||
// an automatic bank-scan. Two flavors:
|
||
// - bank has resolvers → confirm "Scan now?"
|
||
// - bank also empty → info dialog pointing at "add manually" /
|
||
// "scanner" with no scan triggered
|
||
var lists = (rlState && rlState.lists) || [];
|
||
var picked = lists.find(function (l) { return l.name === name; });
|
||
var noScan = false;
|
||
if (picked && (picked.count || 0) === 0) {
|
||
var bankEl = document.getElementById('resolverBankCount');
|
||
var bankCount = bankEl ? (parseInt(bankEl.textContent, 10) || 0) : 0;
|
||
if (bankCount > 0) {
|
||
var ok = await showConfirmDialog(
|
||
(t('empty_list_scan_confirm') || 'List "{n}" is empty. Scan the bank to find working resolvers?').replace('{n}', name),
|
||
t('scan') || 'Scan',
|
||
t('cancel') || 'Cancel');
|
||
if (!ok) noScan = true;
|
||
} else {
|
||
// Bank also empty — show guidance and switch without scan.
|
||
await showInfoDialog(
|
||
t('empty_list_no_bank') || 'This list and the bank are both empty. Open the Bank tab to add resolvers manually, or run the resolver scanner.',
|
||
t('ok') || 'OK');
|
||
noScan = true;
|
||
}
|
||
}
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists/select', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name, noScan: noScan })
|
||
});
|
||
if (!r.ok) {
|
||
showToast(await r.text() || t('resolver_list_switch_failed') || 'Could not switch list');
|
||
loadResolverLists();
|
||
return;
|
||
}
|
||
rlState = await r.json();
|
||
switchResolverTab('active'); // also re-renders tabs
|
||
try { if (typeof _fetchActiveBoard === 'function') _fetchActiveBoard(); } catch (e) { }
|
||
try { if (typeof refreshResolversBadge === 'function') refreshResolversBadge(); } catch (e) { }
|
||
try { await doRefresh(false); } catch (e) { }
|
||
} catch (e) {
|
||
showToast(e.message || 'switch failed');
|
||
}
|
||
}
|
||
|
||
// ----- Inline tab menu (rename / delete on the selected list) -----
|
||
|
||
function toggleResolverTabMenu(anchor) {
|
||
var menu = document.getElementById('resolverTabMenu');
|
||
if (!menu) return;
|
||
if (!menu.hidden) { hideResolverTabMenu(); return; }
|
||
// Defensive: drop any leftover outside-click listener from a
|
||
// previous open before installing a new one.
|
||
document.removeEventListener('click', _resolverTabMenuOutside);
|
||
// Use viewport coordinates from getBoundingClientRect — pairs
|
||
// with position: fixed in CSS. Anchor the menu's top edge just
|
||
// below the ⋮ button, and align horizontally so the menu hangs
|
||
// from the same side as the button (RTL puts it on the right).
|
||
var rect = anchor.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom + 4) + 'px';
|
||
menu.style.bottom = 'auto';
|
||
var isRtl = document.documentElement.dir === 'rtl';
|
||
// First make it visible at a temporary position so we can
|
||
// measure its width and clamp inside the viewport edges.
|
||
menu.hidden = false;
|
||
var menuW = menu.offsetWidth || 140;
|
||
if (isRtl) {
|
||
var rightOffset = window.innerWidth - rect.right;
|
||
// Clamp so the menu doesn't go off-screen on either side.
|
||
rightOffset = Math.max(8, Math.min(rightOffset, window.innerWidth - menuW - 8));
|
||
menu.style.right = rightOffset + 'px';
|
||
menu.style.left = 'auto';
|
||
} else {
|
||
var leftOffset = rect.left;
|
||
leftOffset = Math.max(8, Math.min(leftOffset, window.innerWidth - menuW - 8));
|
||
menu.style.left = leftOffset + 'px';
|
||
menu.style.right = 'auto';
|
||
}
|
||
// Close on next outside click. Defer one tick so the click that
|
||
// opened the menu doesn't immediately close it again.
|
||
setTimeout(function () {
|
||
document.addEventListener('click', _resolverTabMenuOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function hideResolverTabMenu() {
|
||
var menu = document.getElementById('resolverTabMenu');
|
||
if (menu) menu.hidden = true;
|
||
document.removeEventListener('click', _resolverTabMenuOutside);
|
||
}
|
||
|
||
function _resolverTabMenuOutside(e) {
|
||
var menu = document.getElementById('resolverTabMenu');
|
||
if (!menu || menu.hidden) return;
|
||
if (menu.contains(e.target)) return;
|
||
hideResolverTabMenu();
|
||
}
|
||
|
||
// ----- Bank → list picker -----
|
||
// The "+" button on each Bank row opens this popover so the user
|
||
// can pick a target named list. Click a list → POST add → toast.
|
||
var bankAddPickerAddr = '';
|
||
|
||
function openBankAddPicker(anchor, addr) {
|
||
// Strip any pending outside-click listener from a previous
|
||
// open. Without this, clicking + on a second row while the
|
||
// menu is still open lets the stale listener fire on the new
|
||
// click and snap-close the freshly-opened menu — looks like
|
||
// "+ doesn't work" from the user's seat.
|
||
hideBankAddMenu();
|
||
bankAddPickerAddr = addr;
|
||
var menu = document.getElementById('bankAddMenu');
|
||
if (!menu) return;
|
||
var lists = (rlState && rlState.lists) || [];
|
||
menu.innerHTML = '';
|
||
if (!lists.length) {
|
||
var empty = document.createElement('div');
|
||
empty.style.padding = '8px 12px';
|
||
empty.style.color = 'var(--text-dim)';
|
||
empty.style.fontSize = '12px';
|
||
empty.textContent = t('no_lists') || 'No saved lists';
|
||
menu.appendChild(empty);
|
||
} else {
|
||
lists.forEach(function (li) {
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.onclick = function () { addBankAddrToList(li.name); };
|
||
var name = document.createElement('span');
|
||
name.textContent = li.name;
|
||
var count = document.createElement('span');
|
||
count.style.color = 'var(--text-dim)';
|
||
count.style.fontSize = '11px';
|
||
count.style.marginInlineStart = '6px';
|
||
count.textContent = '(' + (li.count || 0) + ')';
|
||
b.appendChild(name);
|
||
b.appendChild(count);
|
||
menu.appendChild(b);
|
||
});
|
||
}
|
||
// Position with viewport coords (matches resolver-tab-menu).
|
||
var rect = anchor.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom + 4) + 'px';
|
||
menu.style.bottom = 'auto';
|
||
menu.hidden = false;
|
||
var menuW = menu.offsetWidth || 160;
|
||
var isRtl = document.documentElement.dir === 'rtl';
|
||
if (isRtl) {
|
||
var rightOffset = window.innerWidth - rect.right;
|
||
rightOffset = Math.max(8, Math.min(rightOffset, window.innerWidth - menuW - 8));
|
||
menu.style.right = rightOffset + 'px';
|
||
menu.style.left = 'auto';
|
||
} else {
|
||
var leftOffset = rect.left;
|
||
leftOffset = Math.max(8, Math.min(leftOffset, window.innerWidth - menuW - 8));
|
||
menu.style.left = leftOffset + 'px';
|
||
menu.style.right = 'auto';
|
||
}
|
||
// One-shot outside click closes. Defer one tick so the click
|
||
// that opened this menu doesn't immediately close it.
|
||
setTimeout(function () {
|
||
document.addEventListener('click', _bankAddMenuOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function hideBankAddMenu() {
|
||
var menu = document.getElementById('bankAddMenu');
|
||
if (menu) menu.hidden = true;
|
||
document.removeEventListener('click', _bankAddMenuOutside);
|
||
}
|
||
|
||
function _bankAddMenuOutside(e) {
|
||
var menu = document.getElementById('bankAddMenu');
|
||
if (!menu || menu.hidden) return;
|
||
if (menu.contains(e.target)) return;
|
||
hideBankAddMenu();
|
||
}
|
||
|
||
async function addBankAddrToList(listName) {
|
||
var addr = bankAddPickerAddr;
|
||
hideBankAddMenu();
|
||
if (!addr || !listName) return;
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists/add', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: listName, resolvers: [addr] })
|
||
});
|
||
if (!r.ok) {
|
||
showToast(await r.text() || t('add_failed') || 'Add failed');
|
||
return;
|
||
}
|
||
var data = await r.json();
|
||
if (data.added > 0) {
|
||
showToast((t('added_to_list') || 'Added to "{n}"').replace('{n}', listName));
|
||
} else {
|
||
showToast((t('already_in_list') || 'Already in "{n}"').replace('{n}', listName));
|
||
}
|
||
} catch (e) { showToast(e.message || 'add failed'); }
|
||
}
|
||
|
||
async function renameCurrentResolverList() {
|
||
hideResolverTabMenu();
|
||
var current = (rlState.lists || []).find(function (li) { return li.selected; });
|
||
if (!current) return;
|
||
var newName = await showInputDialog({
|
||
title: t('rename') || 'Rename',
|
||
message: (t('resolver_list_rename_prompt') || 'New name for "{n}"').replace('{n}', current.name),
|
||
value: current.name,
|
||
maxLength: 32,
|
||
});
|
||
if (!newName || newName === current.name) return;
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists/rename', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: current.name, newName: newName })
|
||
});
|
||
if (!r.ok) {
|
||
showToast(await r.text() || t('rename_failed') || 'Rename failed');
|
||
return;
|
||
}
|
||
rlState = await r.json();
|
||
renderResolverTabs();
|
||
} catch (e) { showToast(e.message || 'rename failed'); }
|
||
}
|
||
|
||
async function deleteCurrentResolverList() {
|
||
hideResolverTabMenu();
|
||
var current = (rlState.lists || []).find(function (li) { return li.selected; });
|
||
if (!current) return;
|
||
var ok = await showConfirmDialog(
|
||
(t('resolver_list_delete_confirm') || 'Delete "{n}"?').replace('{n}', current.name),
|
||
t('delete') || 'Delete',
|
||
t('cancel') || 'Cancel');
|
||
if (!ok) return;
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists', {
|
||
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: current.name })
|
||
});
|
||
if (!r.ok) {
|
||
showToast(await r.text() || t('delete_failed') || 'Delete failed');
|
||
return;
|
||
}
|
||
rlState = await r.json();
|
||
renderResolverTabs();
|
||
try { if (typeof _fetchActiveBoard === 'function') _fetchActiveBoard(); } catch (e) { }
|
||
} catch (e) { showToast(e.message || 'delete failed'); }
|
||
}
|
||
|
||
async function saveCurrentResolverListPrompt() {
|
||
var name = await showInputDialog({
|
||
title: t('save_current_list') || 'New list',
|
||
message: t('resolver_list_name_prompt') || 'Name this list:',
|
||
placeholder: t('resolver_list_name_ph') || 'List name',
|
||
maxLength: 32,
|
||
});
|
||
if (!name) return;
|
||
try {
|
||
var r = await fetch('/api/resolvers/lists/save', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name, mode: 'create' })
|
||
});
|
||
if (r.status === 409) {
|
||
var ok = await showConfirmDialog(
|
||
(t('resolver_list_overwrite_confirm') || 'A list named "{n}" already exists. Overwrite?').replace('{n}', name),
|
||
t('overwrite') || 'Overwrite',
|
||
t('cancel') || 'Cancel');
|
||
if (!ok) return;
|
||
r = await fetch('/api/resolvers/lists/save', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name, mode: 'overwrite' })
|
||
});
|
||
}
|
||
if (!r.ok) {
|
||
showToast(await r.text() || t('save_failed') || 'Save failed');
|
||
return;
|
||
}
|
||
rlState = await r.json();
|
||
renderResolverTabs();
|
||
showToast((t('resolver_list_saved') || 'Saved "{n}"').replace('{n}', name));
|
||
} catch (e) { showToast(e.message || 'save failed'); }
|
||
}
|
||
|
||
async function clearCache() {
|
||
try { await mediaWipeIDB(); } catch (e) { }
|
||
Object.keys(mediaBlobURLs).forEach(function (k) {
|
||
try { URL.revokeObjectURL(mediaBlobURLs[k]); } catch (e) { }
|
||
});
|
||
mediaBlobURLs = {};
|
||
mediaBlobs = {};
|
||
// Clear last-seen tracking. Keep thefeed_lang / thefeed_theme /
|
||
// version-update flags. Profiles live server-side, untouched.
|
||
try {
|
||
localStorage.removeItem('thefeed_seen_ids');
|
||
localStorage.removeItem('thefeed_seen_hashes');
|
||
var toRemove = [];
|
||
for (var i = 0; i < localStorage.length; i++) {
|
||
var k = localStorage.key(i);
|
||
if (k && k.indexOf('thefeed_seen_ts_') === 0) toRemove.push(k);
|
||
}
|
||
toRemove.forEach(function (k) { localStorage.removeItem(k); });
|
||
} catch (e) { }
|
||
previousMsgIDs = {};
|
||
previousContentHashes = {};
|
||
autoFetchedChannels = {};
|
||
try { var r = await fetch('/api/cache/clear', { method: 'POST' }); var j = await r.json(); if (j.ok) { alert(t('cache_cleared')) } } catch (e) { }
|
||
// Server cache was just wiped; force a fresh DNS-backed refresh of the
|
||
// selected channel. Other channels auto-refresh on first open.
|
||
try { await doRefresh(false); } catch (e) { }
|
||
}
|
||
|
||
async function mediaWipeIDB() {
|
||
var db = await mediaDBOpen();
|
||
if (!db) return;
|
||
try {
|
||
var tx = db.transaction(MEDIA_DB_STORE, 'readwrite');
|
||
tx.objectStore(MEDIA_DB_STORE).clear();
|
||
} catch (e) { }
|
||
}
|
||
|
||
// ===== SAVED RESOLVERS PROMPT =====
|
||
function checkAndShowSavedResolversPrompt(status) {
|
||
// Suppressed by the user via "Don't show again" → settings can
|
||
// re-enable it.
|
||
if (localStorage.getItem('thefeed_scan_prompt_off') === '1') return;
|
||
// Per-tab debounce so we don't re-pop on every soft reload
|
||
// within the same browsing session.
|
||
if (sessionStorage.getItem('thefeed_scan_prompt_shown')) return;
|
||
var hasSaved = !!(status.lastScan && status.lastScan.count);
|
||
var msg;
|
||
if (hasSaved) {
|
||
var ls = status.lastScan;
|
||
var ageSec = Math.floor(Date.now() / 1000) - ls.scannedAt;
|
||
var ageStr;
|
||
if (ageSec < 3600) ageStr = Math.max(1, Math.round(ageSec / 60)) + ' ' + t('minutes_ago');
|
||
else ageStr = Math.round(ageSec / 3600) + ' ' + t('hours_ago');
|
||
msg = t('saved_resolvers_msg').replace('{n}', ls.count).replace('{t}', ageStr);
|
||
} else {
|
||
// Fresh install / wiped state — there's nothing to "use", so
|
||
// the prompt becomes a plain "want to scan?" question.
|
||
msg = t('startup_scan_msg') || 'Run a scan to find working resolvers?';
|
||
}
|
||
document.getElementById('savedResolversMsg').textContent = msg;
|
||
// Hide "Use Now" when we have nothing saved to apply.
|
||
var useBtn = document.getElementById('savedResolversUseBtn');
|
||
if (useBtn) useBtn.style.display = hasSaved ? '' : 'none';
|
||
document.getElementById('savedResolversModal').classList.add('active');
|
||
}
|
||
function savedResolversSkip() {
|
||
// "Later" — just close, server already applied saved resolvers and refresh is underway
|
||
document.getElementById('savedResolversModal').classList.remove('active');
|
||
sessionStorage.setItem('thefeed_scan_prompt_shown', '1');
|
||
}
|
||
function savedResolversNever() {
|
||
// "Don't show again" — persist across sessions. localStorage
|
||
// for fast access, /api/settings for survival across Android
|
||
// restarts (the WebView origin port changes each launch, so
|
||
// localStorage alone gets reset).
|
||
localStorage.setItem('thefeed_scan_prompt_off', '1');
|
||
try {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scanPromptOff: true })
|
||
});
|
||
} catch (e) { }
|
||
savedResolversSkip();
|
||
showToast(t('startup_scan_off') || 'Startup scan prompt disabled');
|
||
}
|
||
function savedResolversUseNow() {
|
||
// Server already applied saved resolvers at startup; just close the popup
|
||
savedResolversSkip();
|
||
showToast(t('saved_resolvers_applied'));
|
||
}
|
||
async function savedResolversRescan() {
|
||
savedResolversSkip();
|
||
try { await fetch('/api/rescan', { method: 'POST' }) } catch (e) { }
|
||
showToast(t('rescan_started'));
|
||
}
|
||
|
||
// ===== SSE =====
|
||
function connectSSE() {
|
||
if (eventSource) eventSource.close();
|
||
// Clear stale progress items from a previous connection
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
resolverScanHint = '';
|
||
eventSource = new EventSource('/api/events');
|
||
eventSource.addEventListener('log', function (e) { addLogLine(JSON.parse(e.data)) });
|
||
eventSource.addEventListener('update', async function (e) {
|
||
refreshResolversBadge();
|
||
var data; try { data = JSON.parse(e.data) } catch (x) { data = e.data }
|
||
// Server signals that the saved resolver lists changed (auto-
|
||
// populated from a bank scan, or user-triggered rescan
|
||
// overwrote them). Refresh the tab strip so the count
|
||
// badge stops showing 0 while resolvers are actually live.
|
||
if (data === 'resolver-lists') {
|
||
if (document.getElementById('resolversModal').classList.contains('active')) {
|
||
try { loadResolverLists(); } catch (e2) { }
|
||
try { if (typeof _fetchActiveBoard === 'function') _fetchActiveBoard(); } catch (e2) { }
|
||
}
|
||
return;
|
||
}
|
||
// Another tab/device switched the active profile.
|
||
if (data === 'profiles') {
|
||
var prevActive = activeProfileId;
|
||
await loadProfiles();
|
||
if (activeProfileId !== prevActive) {
|
||
// The active profile changed under us — drop stale local state
|
||
// so the user doesn't see the old profile's channel/messages.
|
||
selectedChannel = 0;
|
||
channels = [];
|
||
document.getElementById('chatName').textContent = 'thefeed';
|
||
document.getElementById('chatSub').textContent = '';
|
||
openSidebar();
|
||
}
|
||
if (document.getElementById('profilesModal').classList.contains('active')) {
|
||
renderProfilesModal();
|
||
}
|
||
return;
|
||
}
|
||
var wasEmpty = channels.length === 0;
|
||
var snapChannel = selectedChannel;
|
||
await loadChannels();
|
||
if (wasEmpty && channels.length > 0 && selectedChannel === 0) {
|
||
closeProfiles();
|
||
// Flip to the sidebar on mobile so the user can see and pick a
|
||
// channel — the empty-state hint is invisible while the chat
|
||
// panel covers the screen.
|
||
openSidebar();
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + (t('select_channel_hint') || '') + '</p></div>';
|
||
return;
|
||
}
|
||
if (data && typeof data === 'object' && data.type === 'no_changes') {
|
||
// Only show the toast if the user explicitly asked for this
|
||
// refresh AND is still on the same channel. Channel-open and
|
||
// auto-refresh ticks stay silent.
|
||
if (data.channel === manualRefreshChannel && data.channel === selectedChannel) {
|
||
showToast(t('no_new_messages'));
|
||
}
|
||
if (data.channel === manualRefreshChannel) manualRefreshChannel = 0;
|
||
if (snapChannel > 0) { delete refreshingChannels[snapChannel]; var fb = document.getElementById('prog-fetch-ch-' + snapChannel); if (fb) fb.remove() }
|
||
} else if (data && typeof data === 'object' && data.channel) {
|
||
delete refreshingChannels[data.channel]; var fb2 = document.getElementById('prog-fetch-ch-' + data.channel); if (fb2) fb2.remove();
|
||
if (data.channel === manualRefreshChannel) manualRefreshChannel = 0;
|
||
// Re-render only when the chat panel is actually visible.
|
||
// On mobile the panel slides on/off via a CSS transform; if
|
||
// the user backs out while a fetch is in flight, rendering
|
||
// mid-transition stalls the slide and the layout sticks at
|
||
// ~90% chat / 10% sidebar (back button looks frozen).
|
||
if (data.channel === selectedChannel && _chatPanelVisible()) await loadMessages(data.channel)
|
||
} else if (snapChannel > 0 && snapChannel === selectedChannel) {
|
||
delete refreshingChannels[snapChannel]; var fb3 = document.getElementById('prog-fetch-ch-' + snapChannel); if (fb3) fb3.remove();
|
||
if (_chatPanelVisible()) await loadMessages(snapChannel)
|
||
}
|
||
updateSendPanel();
|
||
});
|
||
eventSource.onerror = function () {
|
||
if (eventSource.readyState === EventSource.CLOSED) { eventSource.close(); setTimeout(connectSSE, 3000) }
|
||
};
|
||
}
|
||
|
||
// ===== PROFILES =====
|
||
async function loadProfiles() {
|
||
try {
|
||
var r = await fetch('/api/profiles'); profiles = await r.json();
|
||
activeProfileId = profiles.active || '';
|
||
renderProfileBtn();
|
||
} catch (e) { profiles = null }
|
||
}
|
||
|
||
function renderProfileBtn() {
|
||
var nameEl = document.getElementById('profileBtnName');
|
||
var avatarEl = document.getElementById('profileBtnAvatar');
|
||
if (!profiles || !profiles.profiles || !profiles.profiles.length) {
|
||
nameEl.textContent = t('set_up'); avatarEl.textContent = '?'; return
|
||
}
|
||
var active = profiles.profiles.find(function (p) { return p.id === activeProfileId });
|
||
var display = (active && (active.nickname || active.config.domain)) || t('profiles');
|
||
nameEl.textContent = display;
|
||
avatarEl.textContent = (active && (active.nickname || active.config.domain) || '?').charAt(0).toUpperCase();
|
||
}
|
||
|
||
function openProfiles() {
|
||
document.getElementById('profilesModal').classList.add('active');
|
||
document.getElementById('importError').style.display = 'none';
|
||
document.getElementById('importSuccess').style.display = 'none';
|
||
document.getElementById('importUriInput').value = '';
|
||
renderProfilesModal();
|
||
}
|
||
function closeProfiles() { document.getElementById('profilesModal').classList.remove('active') }
|
||
|
||
function buildProfileUri(id, selectedResolvers) {
|
||
if (!profiles || !profiles.profiles) return '';
|
||
var p = profiles.profiles.find(function (x) { return x.id === id });
|
||
if (!p || !p.config.domain) return '';
|
||
var resolvers = selectedResolvers || [];
|
||
// Include the user-set nickname so the recipient lands on a
|
||
// pre-named profile instead of "thefeed.example.com" pulled
|
||
// from the domain. Capped at 32 chars on this side; the
|
||
// import side enforces the same cap defensively.
|
||
var uri = 'thefeed://' + encodeURIComponent(p.config.domain)
|
||
+ '/' + encodeURIComponent(p.config.key)
|
||
+ '?r=' + encodeURIComponent(resolvers.join(','));
|
||
var nick = (p.nickname || '').trim().slice(0, 32);
|
||
if (nick && nick !== p.config.domain) {
|
||
uri += '&n=' + encodeURIComponent(nick);
|
||
}
|
||
return uri;
|
||
}
|
||
|
||
function renderProfilesModal() {
|
||
var el = document.getElementById('profilesListEl');
|
||
if (!profiles || !profiles.profiles || !profiles.profiles.length) {
|
||
el.innerHTML = '<div style="color:var(--text-dim);padding:14px 0;font-size:13px">' + t('no_profiles') + '</div>'; return
|
||
}
|
||
var h = '';
|
||
for (var i = 0; i < profiles.profiles.length; i++) {
|
||
var p = profiles.profiles[i];
|
||
var isActive = p.id === activeProfileId;
|
||
var initial = (p.nickname || p.config.domain || '?').charAt(0).toUpperCase();
|
||
h += '<div class="profile-row' + (isActive ? ' active-profile' : '') + '" id="prow-' + p.id + '">';
|
||
h += '<div class="profile-row-main" onclick="activateProfile(\'' + p.id + '\')">';
|
||
h += '<div class="profile-row-avatar">' + esc(initial) + '</div>';
|
||
h += '<div class="profile-row-info"><div class="profile-row-name">' + esc(p.nickname || p.config.domain);
|
||
if (isActive) h += '<span class="active-badge">' + t('active') + '</span>';
|
||
h += '</div><div class="profile-row-domain">' + esc(p.config.domain) + '</div></div>';
|
||
h += '<div class="profile-row-btns">';
|
||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openShareModal(\'' + p.id + '\')" title="' + t('share') + '" style="font-size:11px">' + t('share') + '</button>';
|
||
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\'' + p.id + '\')" title="' + t('edit') + '" style="font-size:11px">' + t('edit') + '</button>';
|
||
h += '</div></div>';
|
||
h += '</div>';
|
||
}
|
||
el.innerHTML = h;
|
||
}
|
||
|
||
// ===== SHARE PROFILE MODAL =====
|
||
var shareModalProfileId = '';
|
||
|
||
async function openShareModal(id) {
|
||
shareModalProfileId = id;
|
||
var p = (profiles && profiles.profiles || []).find(function (x) { return x.id === id });
|
||
if (!p) return;
|
||
document.getElementById('shareModalProfileName').textContent = p.nickname || p.config.domain;
|
||
// Make sure rlState is loaded — the user may not have opened
|
||
// the Bank modal yet, in which case rlState is its empty
|
||
// default and the source dropdown would only have "Bank".
|
||
if (!rlState || !rlState.lists || !rlState.lists.length) {
|
||
try { await loadResolverLists(); } catch (e) { }
|
||
}
|
||
var src = document.getElementById('shareModalSource');
|
||
src.innerHTML = '';
|
||
var bankOpt = document.createElement('option');
|
||
bankOpt.value = '__bank';
|
||
bankOpt.textContent = (t('resolver_tab_bank') || 'Bank');
|
||
src.appendChild(bankOpt);
|
||
((rlState && rlState.lists) || []).forEach(function (li) {
|
||
var opt = document.createElement('option');
|
||
opt.value = li.name;
|
||
opt.textContent = li.name + ' (' + (li.count || 0) + ')';
|
||
src.appendChild(opt);
|
||
});
|
||
document.getElementById('shareProfileModal').classList.add('active');
|
||
await reloadShareModalResolvers();
|
||
}
|
||
|
||
function closeShareModal() {
|
||
document.getElementById('shareProfileModal').classList.remove('active');
|
||
shareModalProfileId = '';
|
||
}
|
||
|
||
async function reloadShareModalResolvers() {
|
||
var src = document.getElementById('shareModalSource');
|
||
var source = src ? src.value : '__bank';
|
||
var el = document.getElementById('shareModalResolvers');
|
||
if (!el) return;
|
||
el.innerHTML = '<span style="color:var(--text-dim)">' + (t('loading') || '…') + '</span>';
|
||
var addrs = [];
|
||
try {
|
||
if (source === '__bank') {
|
||
var r = await fetch('/api/resolvers/bank');
|
||
var data = r.ok ? await r.json() : { bank: [] };
|
||
(data.bank || []).forEach(function (b) { addrs.push(b.addr); });
|
||
} else {
|
||
var lr = await fetch('/api/resolvers/lists?include=resolvers');
|
||
if (lr.ok) {
|
||
var ld = await lr.json();
|
||
var li = (ld.lists || []).find(function (x) { return x.name === source; });
|
||
if (li && li.resolvers) addrs = li.resolvers.slice();
|
||
}
|
||
}
|
||
} catch (e) { }
|
||
if (!addrs.length) {
|
||
el.innerHTML = '<span style="color:var(--text-dim)">' + (t('no_active_resolvers') || '—') + '</span>';
|
||
updateShareModalUri();
|
||
return;
|
||
}
|
||
var h = '';
|
||
addrs.forEach(function (addr) {
|
||
h += '<label style="display:block;cursor:pointer;padding:2px 0">'
|
||
+ '<input type="checkbox" class="share-modal-cb" value="' + escAttr(addr) + '" checked onchange="updateShareModalUri()" style="width:auto;margin-inline-end:6px;vertical-align:middle">'
|
||
+ esc(addr) + '</label>';
|
||
});
|
||
el.innerHTML = h;
|
||
updateShareModalUri();
|
||
}
|
||
|
||
function toggleAllShareModalResolvers(checked) {
|
||
document.querySelectorAll('.share-modal-cb').forEach(function (cb) { cb.checked = checked });
|
||
updateShareModalUri();
|
||
}
|
||
|
||
function getShareModalUri() {
|
||
var selected = [];
|
||
document.querySelectorAll('.share-modal-cb').forEach(function (cb) { if (cb.checked) selected.push(cb.value) });
|
||
return buildProfileUri(shareModalProfileId, selected);
|
||
}
|
||
|
||
function updateShareModalUri() {
|
||
var uri = getShareModalUri();
|
||
var ta = document.getElementById('shareModalUri');
|
||
if (ta) ta.value = uri || (t('no_config') || '');
|
||
}
|
||
|
||
function copyShareModalUri() {
|
||
var uri = getShareModalUri();
|
||
if (!uri) { showToast(t('no_config')); return }
|
||
navigator.clipboard.writeText(uri).then(function () { showToast(t('copied')) }).catch(function () {
|
||
var ta = document.getElementById('shareModalUri');
|
||
if (ta) { ta.select(); ta.setSelectionRange(0, 9999); }
|
||
showToast(t('copied'));
|
||
});
|
||
}
|
||
|
||
|
||
async function activateProfile(id) {
|
||
if (id === activeProfileId) { closeProfiles(); return }
|
||
var skipCheck = true;
|
||
try {
|
||
var bankR = await fetch('/api/resolvers/bank');
|
||
if (bankR.ok) {
|
||
var bankD = await bankR.json();
|
||
var activeN = 0;
|
||
(bankD.bank || []).forEach(function (b) { if (b.active) activeN++ });
|
||
if (activeN > 0) skipCheck = await askRescan(activeN);
|
||
}
|
||
} catch (e) { }
|
||
try {
|
||
var r = await fetch('/api/profiles/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, skipCheck: skipCheck }) });
|
||
if (!r.ok) return;
|
||
activeProfileId = id; selectedChannel = 0; channels = [];
|
||
refreshingChannels = {};
|
||
progressSilencedUntil = Date.now() + 5000;
|
||
// Reset the chat header so it doesn't keep showing the previous
|
||
// profile's channel name/handle while the user picks a new one.
|
||
document.getElementById('chatName').textContent = 'thefeed';
|
||
document.getElementById('chatSub').textContent = '';
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('switching') + '</p></div>';
|
||
// On desktop the chat panel is always visible; collapse it back to
|
||
// the sidebar so the empty state doesn't sit next to a stale header.
|
||
openSidebar();
|
||
await loadProfiles(); closeProfiles();
|
||
await loadChannels();
|
||
if (channels.length === 0) {
|
||
// No cache for this profile — DNS metadata fetch is in flight; the
|
||
// SSE 'update' handler will replace this with the "pick a channel"
|
||
// hint once channels actually arrive.
|
||
showInitProgress(); await doRefresh();
|
||
} else {
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + (t('select_channel_hint') || '') + '</p></div>';
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
|
||
// ===== IMPORT URI =====
|
||
async function pasteImportUri() {
|
||
var input = document.getElementById('importUriInput');
|
||
if (!input) return;
|
||
try {
|
||
var text = await navigator.clipboard.readText();
|
||
if (text) {
|
||
input.value = text.trim();
|
||
input.focus();
|
||
}
|
||
} catch (e) {
|
||
// Clipboard API can fail (no permission, http context, etc.) —
|
||
// fall back to focus + select-all so the user can Cmd-V into
|
||
// an empty field with one tap.
|
||
input.focus();
|
||
try { input.select(); } catch (e2) { }
|
||
showToast(t('clipboard_blocked') || 'Clipboard blocked — paste manually');
|
||
}
|
||
}
|
||
|
||
async function doImportUri() {
|
||
var errEl = document.getElementById('importError'); var okEl = document.getElementById('importSuccess');
|
||
errEl.style.display = 'none'; okEl.style.display = 'none';
|
||
var uri = document.getElementById('importUriInput').value.trim();
|
||
if (!uri.startsWith('thefeed://')) { errEl.textContent = t('invalid_uri'); errEl.style.display = 'block'; return }
|
||
try {
|
||
var body = uri.substring('thefeed://'.length);
|
||
var qIdx = body.indexOf('?'); var path = qIdx >= 0 ? body.substring(0, qIdx) : body;
|
||
var params = qIdx >= 0 ? body.substring(qIdx + 1) : '';
|
||
var parts = path.split('/');
|
||
var domain = decodeURIComponent(parts[0] || ''); var key = decodeURIComponent(parts[1] || '');
|
||
var resolvers = [];
|
||
var sharedNick = '';
|
||
params.split('&').forEach(function (kv) {
|
||
var eq = kv.indexOf('=');
|
||
if (eq < 0) return;
|
||
var k = kv.substring(0, eq);
|
||
var v = kv.substring(eq + 1);
|
||
if (k === 'r' && v) resolvers = decodeURIComponent(v).split(',').filter(Boolean);
|
||
else if (k === 'n' && v) {
|
||
try { sharedNick = decodeURIComponent(v); } catch (e) { sharedNick = ''; }
|
||
}
|
||
});
|
||
if (!domain || !key) { errEl.textContent = t('uri_missing'); errEl.style.display = 'block'; return }
|
||
// No 8.8.8.8 / 1.1.1.1 fallback — an empty resolver list in
|
||
// the URI is the sender's deliberate choice (e.g. they want
|
||
// the recipient to bring their own bank).
|
||
sharedNick = sharedNick.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 32);
|
||
// Only ask about bank merging when the URI actually carried
|
||
// resolvers — empty list means the sender shared zero, so
|
||
// there's nothing to merge and no prompt to show.
|
||
if (resolvers.length > 0) {
|
||
var bankData = { count: 0 };
|
||
try {
|
||
var bankRes = await fetch('/api/resolvers/bank', { signal: AbortSignal.timeout(5000) });
|
||
if (bankRes.ok) bankData = await bankRes.json();
|
||
} catch (e2) { }
|
||
var shouldAdd = true;
|
||
if (bankData.count > 0 && bankData.count <= 200) {
|
||
shouldAdd = await showConfirmDialog(t('import_add_resolvers').replace('{n}', resolvers.length), t('yes'), t('no'));
|
||
} else if (bankData.count > 200) {
|
||
shouldAdd = await showConfirmDialog(t('import_add_resolvers_large').replace('{n}', resolvers.length).replace('{c}', bankData.count), t('yes'), t('no'));
|
||
}
|
||
if (shouldAdd) {
|
||
await fetch('/api/resolvers/bank', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolvers: resolvers }) });
|
||
}
|
||
}
|
||
// Create profile without resolvers (they're in the bank now).
|
||
var profile = { id: '', nickname: sharedNick || domain, config: { domain: domain, key: key, queryMode: 'single', rateLimit: 6 } };
|
||
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', profile: profile }) });
|
||
if (!r.ok) throw new Error('save failed');
|
||
okEl.textContent = t('import_success').replace('{d}', domain); okEl.style.display = 'block';
|
||
document.getElementById('importUriInput').value = '';
|
||
await loadProfiles(); renderProfilesModal();
|
||
refreshResolversBadge();
|
||
} catch (e) { errEl.textContent = t('import_error') + ': ' + e.message; errEl.style.display = 'block' }
|
||
}
|
||
|
||
// ===== PROFILE EDITOR =====
|
||
function openProfileEditor(id) {
|
||
editingProfileId = id;
|
||
document.getElementById('profileEditorModal').classList.add('active');
|
||
document.getElementById('peError').style.display = 'none';
|
||
document.getElementById('peWarning').style.display = 'none';
|
||
if (id) {
|
||
document.getElementById('profileEditorTitle').textContent = t('edit_profile');
|
||
document.getElementById('peDeleteBtn').style.display = '';
|
||
var p = profiles && profiles.profiles && profiles.profiles.find(function (x) { return x.id === id });
|
||
if (p) {
|
||
document.getElementById('peNick').value = p.nickname || '';
|
||
document.getElementById('peDomain').value = p.config.domain || '';
|
||
document.getElementById('peKey').value = p.config.key || '';
|
||
document.getElementById('peQueryMode').value = p.config.queryMode || 'single';
|
||
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
|
||
document.getElementById('peScatter').value = p.config.scatter || 4;
|
||
document.getElementById('peTimeout').value = p.config.timeout || 15;
|
||
}
|
||
document.getElementById('peChannelSection').style.display = '';
|
||
var isActive = id === activeProfileId;
|
||
if (isActive) {
|
||
document.getElementById('peChannelNote').textContent = t('channel_mgmt_note');
|
||
document.getElementById('peAddChannelRow').style.display = 'flex';
|
||
loadEditorChannels();
|
||
} else {
|
||
document.getElementById('peChannelNote').textContent = t('channel_mgmt_inactive');
|
||
document.getElementById('peAddChannelRow').style.display = 'none';
|
||
document.getElementById('peChannelList').innerHTML = '';
|
||
}
|
||
} else {
|
||
document.getElementById('profileEditorTitle').textContent = t('new_profile');
|
||
document.getElementById('peDeleteBtn').style.display = 'none';
|
||
document.getElementById('peNick').value = '';
|
||
document.getElementById('peDomain').value = '';
|
||
document.getElementById('peKey').value = '';
|
||
document.getElementById('peQueryMode').value = 'single';
|
||
document.getElementById('peRateLimit').value = '6';
|
||
document.getElementById('peScatter').value = '4';
|
||
document.getElementById('peTimeout').value = '15';
|
||
document.getElementById('peChannelSection').style.display = 'none';
|
||
}
|
||
}
|
||
function closeProfileEditor() { document.getElementById('profileEditorModal').classList.remove('active'); editingProfileId = null }
|
||
|
||
async function loadEditorChannels() {
|
||
var el = document.getElementById('peChannelList');
|
||
el.innerHTML = '<div style="color:var(--text-dim);font-size:12px">' + t('loading') + '</div>';
|
||
try {
|
||
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'list_channels', arg: '' }) });
|
||
if (!r.ok) throw new Error(await r.text() || 'failed');
|
||
var data = await r.json();
|
||
var chans = (data.result || '').split('\n').filter(function (s) { return s.trim() });
|
||
if (!chans.length) { el.innerHTML = '<div style="color:var(--text-dim);font-size:12px">' + t('no_channels') + '</div>'; return }
|
||
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="' + 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>' }
|
||
}
|
||
|
||
async function addChannelEditor() {
|
||
var input = document.getElementById('peAddChannelInput');
|
||
var u = input.value.trim().replace(/^@/, ''); if (!u) return;
|
||
try {
|
||
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'add_channel', arg: u }) });
|
||
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
||
input.value = ''; addLogLine('Channel added: ' + u); loadEditorChannels();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
|
||
async function removeChannelEditor(u) {
|
||
try {
|
||
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'remove_channel', arg: u }) });
|
||
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
||
addLogLine('Channel removed: ' + u); loadEditorChannels();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
|
||
async function saveProfile() {
|
||
var errEl = document.getElementById('peError'); errEl.style.display = 'none';
|
||
// Trim and clamp the nickname server-side too: the maxlength
|
||
// attribute on the input only stops keyboard typing, not paste
|
||
// / programmatic value setting.
|
||
var nick = document.getElementById('peNick').value.trim().slice(0, 32);
|
||
var domain = document.getElementById('peDomain').value.trim();
|
||
var key = document.getElementById('peKey').value;
|
||
if (!domain || !key) { errEl.textContent = t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
|
||
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
|
||
// Preserve autoScan from existing profile
|
||
if (editingProfileId && profiles && profiles.profiles) {
|
||
var existing = profiles.profiles.find(function (x) { return x.id === editingProfileId });
|
||
if (existing && existing.config.autoScan === false) profile.config.autoScan = false;
|
||
}
|
||
var action = editingProfileId ? 'update' : 'create';
|
||
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
|
||
var skipCheck = true;
|
||
var isActiveEdit = editingProfileId && editingProfileId === activeProfileId;
|
||
if (isActiveEdit || wasFirst) {
|
||
try {
|
||
var bankR = await fetch('/api/resolvers/bank');
|
||
if (bankR.ok) {
|
||
var bankD = await bankR.json();
|
||
var activeN = 0;
|
||
(bankD.bank || []).forEach(function (b) { if (b.active) activeN++ });
|
||
if (activeN > 0) {
|
||
skipCheck = await askRescan(activeN);
|
||
}
|
||
}
|
||
} catch (e) { /* ignore — default to skipCheck=true */ }
|
||
}
|
||
try {
|
||
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, profile: profile, skipCheck: skipCheck }) });
|
||
if (!r.ok) { errEl.textContent = await r.text(); errEl.style.display = 'block'; return }
|
||
await loadProfiles();
|
||
var savedEditId = editingProfileId;
|
||
closeProfileEditor();
|
||
if (wasFirst) {
|
||
closeProfiles();
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';
|
||
showInitProgress();
|
||
} else {
|
||
renderProfilesModal();
|
||
// Only rescan if we updated the currently active profile
|
||
if (savedEditId && savedEditId === activeProfileId) {
|
||
showToast(t('rescan_started'));
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
showInitProgress();
|
||
await loadChannels();
|
||
if (selectedChannel > 0) await loadMessages(selectedChannel);
|
||
}
|
||
}
|
||
} catch (e) { errEl.textContent = e.message; errEl.style.display = 'block' }
|
||
}
|
||
|
||
async function deleteEditingProfile() {
|
||
if (!editingProfileId) return;
|
||
if (!confirm(t('delete') + '?')) return;
|
||
try {
|
||
await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', profile: { id: editingProfileId } }) });
|
||
await loadProfiles(); closeProfileEditor();
|
||
if (document.getElementById('profilesModal').classList.contains('active')) renderProfilesModal();
|
||
} catch (e) { }
|
||
}
|
||
|
||
// ===== CHANNELS =====
|
||
async function loadChannels() {
|
||
try {
|
||
var r = await fetch('/api/channels'); channels = await r.json(); if (!channels) channels = [];
|
||
// Initialise NEW-badge baseline for any channel we've never seen
|
||
// before. Without this the badge can never fire on a channel the
|
||
// user hasn't manually opened yet (since channelHasNew suppresses
|
||
// when no baseline exists).
|
||
var baselineDirty = false;
|
||
for (var ci = 0; ci < channels.length; ci++) {
|
||
var ch0 = channels[ci];
|
||
var n0 = ch0.Name || ch0.name || '';
|
||
if (!n0) continue;
|
||
if (previousContentHashes[n0] === undefined) {
|
||
previousContentHashes[n0] = ch0.ContentHash || ch0.contentHash || 0;
|
||
baselineDirty = true;
|
||
}
|
||
if (!(n0 in previousMsgIDs)) {
|
||
previousMsgIDs[n0] = ch0.LastMsgID || ch0.lastMsgID || 0;
|
||
baselineDirty = true;
|
||
}
|
||
}
|
||
if (baselineDirty) {
|
||
saveSeenMap('thefeed_seen_ids', previousMsgIDs);
|
||
saveSeenMap('thefeed_seen_hashes', previousContentHashes);
|
||
}
|
||
await loadAutoUpdate();
|
||
renderChannels(); updateSendPanel();
|
||
var initBar = document.getElementById('prog-init'); if (initBar) initBar.remove();
|
||
var sr = await fetch('/api/status'); var st = await sr.json();
|
||
telegramLoggedIn = !!st.telegramLoggedIn;
|
||
if (st.nextFetch) { serverNextFetch = st.nextFetch; updateNextFetchDisplay() }
|
||
if (st.latestVersion) { latestVersion = st.latestVersion; renderLatestVersion() }
|
||
} catch (e) { }
|
||
}
|
||
|
||
// UI cache of the active profile's auto-update list (server is source of truth).
|
||
var autoUpdateChannels = new Set();
|
||
async function loadAutoUpdate() {
|
||
try {
|
||
var r = await fetch('/api/auto-update');
|
||
if (!r.ok) return;
|
||
var d = await r.json();
|
||
autoUpdateChannels = new Set();
|
||
if (Array.isArray(d.channels)) {
|
||
for (var i = 0; i < d.channels.length; i++) {
|
||
var n = String(d.channels[i] || '').replace(/^@/, '').trim();
|
||
if (n) autoUpdateChannels.add(n);
|
||
}
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
async function toggleAutoUpdate(name, ev) {
|
||
if (ev) { ev.stopPropagation(); ev.preventDefault(); }
|
||
var clean = String(name || '').replace(/^@/, '').trim();
|
||
if (!clean) return;
|
||
// Optimistic flip; rollback if the server rejects.
|
||
if (autoUpdateChannels.has(clean)) autoUpdateChannels.delete(clean);
|
||
else autoUpdateChannels.add(clean);
|
||
renderChannels();
|
||
try {
|
||
var r = await fetch('/api/auto-update/toggle', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ channel: clean })
|
||
});
|
||
if (!r.ok) {
|
||
if (autoUpdateChannels.has(clean)) autoUpdateChannels.delete(clean);
|
||
else autoUpdateChannels.add(clean);
|
||
renderChannels();
|
||
return;
|
||
}
|
||
var d = await r.json();
|
||
autoUpdateChannels = new Set();
|
||
if (Array.isArray(d.channels)) {
|
||
for (var i = 0; i < d.channels.length; i++) {
|
||
var n = String(d.channels[i] || '').replace(/^@/, '').trim();
|
||
if (n) autoUpdateChannels.add(n);
|
||
}
|
||
}
|
||
renderChannels();
|
||
var template = d.enabled
|
||
? (t('auto_update_on') || 'Auto-update is now ON for @{name}')
|
||
: (t('auto_update_off') || 'Auto-update is now OFF for @{name}');
|
||
showToast(template.replace('{name}', d.channel || clean), 5000);
|
||
} catch (e) { }
|
||
}
|
||
|
||
var _renderChannelsTimer = null;
|
||
function renderChannels() {
|
||
// Debounce: avoid rapid sequential DOM rebuilds that cause hover flicker
|
||
if (_renderChannelsTimer) clearTimeout(_renderChannelsTimer);
|
||
_renderChannelsTimer = setTimeout(_renderChannelsNow, 50);
|
||
}
|
||
function _renderChannelsNow() {
|
||
_renderChannelsTimer = null;
|
||
var el = document.getElementById('channelList');
|
||
if (!channels || !channels.length) {
|
||
var _hint = resolverScanHint || (t('no_channels_hint') + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> ' + t('no_channels_hint2'));
|
||
el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:13px">' + t('no_channels') + '<br><span id="no-ch-hint" style="font-size:11px;opacity:.7;line-height:1.8">' + _hint + '</span></div>'; return
|
||
}
|
||
// Fast-path: if channel count matches existing items, just update classes/badges
|
||
var existingItems = el.querySelectorAll('.ch-item');
|
||
if (existingItems.length === channels.length) {
|
||
var needsFullRebuild = false;
|
||
for (var ci = 0; ci < channels.length; ci++) {
|
||
var ch = channels[ci], nm = ch.Name || ch.name || 'Channel ' + (ci + 1);
|
||
var lbl = ch.DisplayName || ch.displayName || nm;
|
||
if (existingItems[ci].dataset.name !== nm || existingItems[ci].dataset.label !== lbl) { needsFullRebuild = true; break }
|
||
}
|
||
if (!needsFullRebuild) {
|
||
for (var ui = 0; ui < channels.length; ui++) {
|
||
var num = ui + 1;
|
||
existingItems[ui].classList.toggle('active', num === selectedChannel);
|
||
var chNm = channels[ui].Name || channels[ui].name || '';
|
||
var showBadge = channelHasNew(channels[ui]) && num !== selectedChannel;
|
||
var previewEl = existingItems[ui].querySelector('.ch-preview');
|
||
if (previewEl) previewEl.innerHTML = showBadge ? '<span class="ch-badge">NEW</span>' : '';
|
||
var autoBtn = existingItems[ui].querySelector('.ch-autoupdate');
|
||
if (autoBtn) {
|
||
var key = chNm.replace(/^@/, '').trim();
|
||
autoBtn.classList.toggle('on', autoUpdateChannels.has(key));
|
||
}
|
||
}
|
||
_updateRefreshBadge(); return;
|
||
}
|
||
}
|
||
var pubs = [], privs = [], xposts = [];
|
||
for (var i = 0; i < channels.length; i++) {
|
||
var c = channels[i];
|
||
var ct = c.ChatType || c.chatType || 0;
|
||
if (ct === 2) xposts.push({ ch: c, idx: i });
|
||
else if (ct === 1) privs.push({ ch: c, idx: i });
|
||
else pubs.push({ ch: c, idx: i });
|
||
}
|
||
function section(title, items) {
|
||
if (!items.length) return ''; var h = '';
|
||
if (title) h += '<div class="channel-section-title">' + esc(title) + '</div>';
|
||
for (var j = 0; j < items.length; j++) {
|
||
var e = items[j], num2 = e.idx + 1;
|
||
var handle = e.ch.Name || e.ch.name || 'Channel ' + num2;
|
||
var label = e.ch.DisplayName || e.ch.displayName || handle;
|
||
var ct2 = e.ch.ChatType || e.ch.chatType || 0;
|
||
var isPriv = e.ch.ChatType === 1 || e.ch.chatType === 1;
|
||
var isX = ct2 === 2;
|
||
var avatarName = label;
|
||
if (isX && handle.toLowerCase().indexOf('x/') === 0) avatarName = handle.substring(2);
|
||
if (avatarName.charAt(0) === '@') avatarName = avatarName.substring(1);
|
||
var avatarText = (avatarName || '?').charAt(0).toUpperCase();
|
||
var active = num2 === selectedChannel ? ' active' : '';
|
||
var chNm2 = e.ch.Name || e.ch.name || '';
|
||
var badge = (channelHasNew(e.ch) && num2 !== selectedChannel) ? '<span class="ch-badge">NEW</span>' : '';
|
||
h += '<div class="ch-item' + active + '" data-name="' + escAttr(handle) + '" data-label="' + escAttr(label) + '" onclick="selectChannel(' + num2 + ')">';
|
||
h += '<div class="ch-avatar">' + esc(avatarText) + '</div>';
|
||
var chSubText = !isX ? (handle.charAt(0) === '@' ? handle : '@' + handle) : '';
|
||
h += '<div class="ch-info"><div class="ch-name">' + formatIranTitleHtml(label) + (isPriv ? '<span class="ch-type-tag">' + t('private') + '</span>' : (isX ? '<span class="ch-type-tag x-tag">' + t('x_label') + '</span>' : '')) + '</div>';
|
||
if (chSubText) h += '<div class="ch-sub">' + esc(chSubText) + '</div>';
|
||
h += '<div class="ch-preview">' + badge + '</div></div>';
|
||
var autoKey = handle.replace(/^@/, '').trim();
|
||
var autoOn = autoUpdateChannels.has(autoKey);
|
||
var autoTitle = esc(t('auto_update_toggle') || 'Auto-update this channel');
|
||
h += '<button type="button" class="ch-autoupdate' + (autoOn ? ' on' : '') + '"'
|
||
+ ' title="' + autoTitle + '" aria-label="' + autoTitle + '"'
|
||
+ ' onclick="toggleAutoUpdate(\'' + escAttr(autoKey) + '\', event)">⏱</button>';
|
||
h += '</div>';
|
||
}
|
||
return h;
|
||
}
|
||
el.innerHTML = section('', pubs) + section(t('x_posts'), xposts) + section(t('private'), privs);
|
||
_updateRefreshBadge();
|
||
}
|
||
function _updateRefreshBadge() {
|
||
var hasNew = false;
|
||
for (var k = 0; k < channels.length; k++) {
|
||
if (k + 1 === selectedChannel) continue;
|
||
if (channelHasNew(channels[k])) { hasNew = true; break }
|
||
}
|
||
document.getElementById('refreshBtn').classList.toggle('refresh-has-new', hasNew);
|
||
}
|
||
|
||
// channelHasNew returns true when the channel's content has changed
|
||
// since the user last visited it.
|
||
function channelHasNew(ch) {
|
||
if (!ch) return false;
|
||
var name = ch.Name || ch.name || '';
|
||
if (!name) return false;
|
||
var hash = ch.ContentHash || ch.contentHash || 0;
|
||
var lastID = ch.LastMsgID || ch.lastMsgID || 0;
|
||
var ct = ch.ChatType || ch.chatType || 0;
|
||
var seenHash = previousContentHashes[name];
|
||
var seenID = previousMsgIDs[name] || 0;
|
||
if (seenHash === undefined && seenID === 0) return false;
|
||
if (seenHash !== undefined && hash !== 0 && hash !== seenHash) return true;
|
||
if (ct !== 2 && seenID > 0 && lastID > seenID) return true;
|
||
return false;
|
||
}
|
||
|
||
async function selectChannel(num) {
|
||
// Save lastSeen for previous channel and flush any pending sticky commit.
|
||
if (selectedChannel > 0 && currentMaxTimestamp > 0) {
|
||
var prevName = channelName(selectedChannel);
|
||
if (newMsgSepCommitTimer[prevName]) {
|
||
clearTimeout(newMsgSepCommitTimer[prevName]);
|
||
delete newMsgSepCommitTimer[prevName];
|
||
delete newMsgSepLastSeen[prevName];
|
||
}
|
||
setLastSeenTimestamp(prevName, currentMaxTimestamp);
|
||
}
|
||
selectedChannel = num;
|
||
currentMaxMsgID = 0;
|
||
currentMaxTimestamp = 0;
|
||
newMsgScrollDone = false;
|
||
openChat();
|
||
var ch = channels[num - 1]; var name = (ch && (ch.DisplayName || ch.displayName || ch.Name || ch.name)) || 'Channel ' + num;
|
||
document.getElementById('chatName').innerHTML = formatIranTitleHtml(name);
|
||
var chHandle = (ch && (ch.Name || ch.name)) || '';
|
||
var isXCh = ch && (ch.ChatType || ch.chatType) === 2;
|
||
var subHandle = (!isXCh && chHandle) ? (chHandle.charAt(0) === '@' ? chHandle : '@' + chHandle) : '';
|
||
document.getElementById('chatSub').textContent = subHandle;
|
||
renderChannels(); updateSendPanel();
|
||
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';
|
||
document.getElementById('scrollDownBtn').classList.remove('visible');
|
||
await loadMessages(num);
|
||
// Show progress bar and mark channel as refreshing so the bar persists
|
||
// through the metadata fetch + channel fetch (~5 s). The bar is removed
|
||
// when the SSE update arrives with fresh data for this channel.
|
||
showChannelFetchProgress(num, name);
|
||
refreshingChannels[num] = true;
|
||
doRefresh(false);
|
||
}
|
||
|
||
function showChannelFetchProgress(num, name) {
|
||
var panel = document.getElementById('progressPanel');
|
||
var id = 'prog-fetch-ch-' + num;
|
||
var existing = document.getElementById(id);
|
||
if (existing) return;
|
||
var item = document.createElement('div'); item.id = id; item.className = 'progress-item';
|
||
item.dataset.lastUpdate = Date.now();
|
||
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="' + esc(t('dismiss') || 'Dismiss') + '">×</button><div class="progress-label">' + t('fetching_channel') + ' ' + (name || num) + '</div><div class="progress-bar"><div class="progress-fill" style="width:40%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div>';
|
||
panel.insertBefore(item, panel.firstChild);
|
||
setTimeout(function () { var el = document.getElementById(id); if (el) el.remove() }, 15000);
|
||
}
|
||
|
||
function updateSendPanel() {
|
||
var panel = document.getElementById('sendPanel');
|
||
var ch = channels[selectedChannel - 1];
|
||
var canSend = !!(ch && (ch.CanSend || ch.canSend));
|
||
if (selectedChannel > 0 && telegramLoggedIn && canSend) panel.classList.add('visible');
|
||
else panel.classList.remove('visible');
|
||
}
|
||
|
||
// ===== MESSAGES =====
|
||
// Tracks channels we've already auto-fetched on first open so we don't
|
||
// re-trigger a refresh for genuinely empty channels.
|
||
var autoFetchedChannels = {};
|
||
async function loadMessages(chNum) {
|
||
try {
|
||
var r = await fetch('/api/messages/' + chNum); if (chNum !== selectedChannel) return;
|
||
var data = await r.json(); if (chNum !== selectedChannel) return;
|
||
renderMessages(data.messages || [], data.gaps || []);
|
||
// If the server has nothing cached for this channel and we haven't
|
||
// already kicked off a fetch this session, trigger one. Covers the
|
||
// post-clear / fresh-restart case where the on-disk cache is empty.
|
||
if ((!data.messages || data.messages.length === 0) && !autoFetchedChannels[chNum] && !refreshingChannels[chNum]) {
|
||
autoFetchedChannels[chNum] = true;
|
||
refreshingChannels[chNum] = true;
|
||
var ch = channels[chNum - 1];
|
||
if (ch) showChannelFetchProgress(chNum, ch.Name || ch.name || '');
|
||
try { await fetch('/api/refresh?channel=' + chNum, { method: 'POST' }); } catch (e) { }
|
||
// Fail-safe: if SSE never delivers the 'channels' update (server
|
||
// refresh failed silently, transport dropped, etc.), clear the
|
||
// flag after 60s so the user can manually retry.
|
||
setTimeout(function () {
|
||
if (refreshingChannels[chNum]) {
|
||
delete refreshingChannels[chNum];
|
||
var fb = document.getElementById('prog-fetch-ch-' + chNum);
|
||
if (fb) fb.remove();
|
||
}
|
||
}, 60000);
|
||
}
|
||
if (!refreshingChannels[chNum]) {
|
||
var fetchBar = document.getElementById('prog-fetch-ch-' + chNum); if (fetchBar) fetchBar.remove();
|
||
}
|
||
if (channels[chNum - 1]) {
|
||
var cn = channels[chNum - 1].Name || channels[chNum - 1].name || '';
|
||
rememberSeen(
|
||
cn,
|
||
channels[chNum - 1].LastMsgID || channels[chNum - 1].lastMsgID || 0,
|
||
channels[chNum - 1].ContentHash || channels[chNum - 1].contentHash || 0
|
||
);
|
||
renderChannels();
|
||
}
|
||
} 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(ln) + '</div>';
|
||
hasContent = true;
|
||
}
|
||
}
|
||
if (!hasContent) html += '<div class="poll-question" style="opacity:.5">' + t('poll_placeholder') + '</div>';
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
// ===== DOWNLOADABLE MEDIA =====
|
||
var mediaInflight = {};
|
||
var mediaBlobURLs = {};
|
||
var mediaBlobs = {};
|
||
|
||
var MEDIA_MAX_CONCURRENT = 1;
|
||
var mediaActiveCount = 0;
|
||
var mediaQueue = []; // [{ domID, run }]
|
||
var mediaQueued = {}; // domID → true
|
||
|
||
// Content-addressed: keyed by "<size>-<crc>" so identical bytes share a
|
||
// single cache entry across messages, channels, and server reboots
|
||
// (the dynamic media-channel number isn't part of the key).
|
||
var MEDIA_DB_NAME = 'thefeed-media';
|
||
var MEDIA_DB_VERSION = 2;
|
||
var MEDIA_DB_STORE = 'blobs';
|
||
var MEDIA_DB_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||
var mediaDBPromise = null;
|
||
|
||
var _crc32Table = (function () {
|
||
var t = new Uint32Array(256);
|
||
for (var n = 0; n < 256; n++) {
|
||
var c = n;
|
||
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||
t[n] = c >>> 0;
|
||
}
|
||
return t;
|
||
})();
|
||
function crc32IEEE(uint8Array) {
|
||
var c = 0xffffffff;
|
||
for (var i = 0; i < uint8Array.length; i++) {
|
||
c = (_crc32Table[(c ^ uint8Array[i]) & 0xff] ^ (c >>> 8)) >>> 0;
|
||
}
|
||
return ((c ^ 0xffffffff) >>> 0);
|
||
}
|
||
async function blobCRC32(blob) {
|
||
var buf = await blob.arrayBuffer();
|
||
return crc32IEEE(new Uint8Array(buf));
|
||
}
|
||
|
||
function mediaDBOpen() {
|
||
if (mediaDBPromise) return mediaDBPromise;
|
||
if (!window.indexedDB) return Promise.resolve(null);
|
||
mediaDBPromise = new Promise(function (resolve) {
|
||
var req = indexedDB.open(MEDIA_DB_NAME, MEDIA_DB_VERSION);
|
||
req.onupgradeneeded = function () {
|
||
var db = req.result;
|
||
// Drop the previous keyspace (channel-keyed entries from v1) so we
|
||
// don't leak orphaned bytes that the new content-addressed lookup
|
||
// can't find.
|
||
if (db.objectStoreNames.contains(MEDIA_DB_STORE)) {
|
||
db.deleteObjectStore(MEDIA_DB_STORE);
|
||
}
|
||
db.createObjectStore(MEDIA_DB_STORE);
|
||
};
|
||
req.onsuccess = function () { resolve(req.result); };
|
||
req.onerror = function () { resolve(null); };
|
||
});
|
||
return mediaDBPromise;
|
||
}
|
||
|
||
function mediaCacheKey(size, crc) { return size + '-' + crc; }
|
||
|
||
async function mediaForgetCache(card) {
|
||
try {
|
||
var size = card.getAttribute('data-size');
|
||
var crc = card.getAttribute('data-crc');
|
||
if (!size || !crc) return;
|
||
var db = await mediaDBOpen();
|
||
if (!db) return;
|
||
var tx = db.transaction(MEDIA_DB_STORE, 'readwrite');
|
||
tx.objectStore(MEDIA_DB_STORE)['delete'](mediaCacheKey(size, crc));
|
||
} catch (e) { }
|
||
}
|
||
|
||
async function mediaPersistBlob(msgID, card, blob, mime) {
|
||
try {
|
||
var db = await mediaDBOpen();
|
||
if (!db) return;
|
||
var size = card.getAttribute('data-size');
|
||
var crc = card.getAttribute('data-crc');
|
||
if (!size || !crc) return;
|
||
var tx = db.transaction(MEDIA_DB_STORE, 'readwrite');
|
||
tx.objectStore(MEDIA_DB_STORE).put({
|
||
blob: blob,
|
||
mime: mime || '',
|
||
savedAt: Date.now()
|
||
}, mediaCacheKey(size, crc));
|
||
} catch (e) { }
|
||
}
|
||
|
||
async function mediaRestoreFromCache(msgID) {
|
||
var card = document.getElementById('media-' + msgID);
|
||
if (!card) return false;
|
||
var size = card.getAttribute('data-size');
|
||
var crc = card.getAttribute('data-crc');
|
||
if (!size || !crc) return false;
|
||
var db = await mediaDBOpen();
|
||
if (!db) return false;
|
||
try {
|
||
var key = mediaCacheKey(size, crc);
|
||
var tx = db.transaction(MEDIA_DB_STORE, 'readonly');
|
||
var store = tx.objectStore(MEDIA_DB_STORE);
|
||
var rec = await new Promise(function (resolve) {
|
||
var req = store.get(key);
|
||
req.onsuccess = function () { resolve(req.result); };
|
||
req.onerror = function () { resolve(null); };
|
||
});
|
||
if (!rec) return false;
|
||
if (rec.savedAt && Date.now() - rec.savedAt > MEDIA_DB_MAX_AGE_MS) {
|
||
try {
|
||
var d2 = db.transaction(MEDIA_DB_STORE, 'readwrite');
|
||
d2.objectStore(MEDIA_DB_STORE)['delete'](key);
|
||
} catch (e) { }
|
||
return false;
|
||
}
|
||
var blobURL = URL.createObjectURL(rec.blob);
|
||
mediaBlobURLs[msgID] = blobURL;
|
||
mediaBlobs[msgID] = { blob: rec.blob, url: blobURL, mime: rec.mime || '' };
|
||
mediaShowBlob(card, blobURL);
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
function mediaTryRestoreVisibleCards() {
|
||
var cards = document.querySelectorAll('.media-card');
|
||
for (var i = 0; i < cards.length; i++) {
|
||
var card = cards[i];
|
||
var msgID = card.getAttribute('data-msg');
|
||
if (!msgID) continue;
|
||
var entry = mediaBlobs[msgID];
|
||
if (entry) {
|
||
mediaShowBlob(card, entry.url);
|
||
continue;
|
||
}
|
||
if (mediaInflight[msgID]) {
|
||
mediaShowProgress(card);
|
||
continue;
|
||
}
|
||
if (mediaQueued[card.id]) {
|
||
mediaShowQueued(card);
|
||
continue;
|
||
}
|
||
// Disk-cache restore only makes sense for DNS-channel media because
|
||
// the cache is keyed off the DNS channel number; GH-only files are
|
||
// re-fetched on demand.
|
||
var ch = parseInt(card.getAttribute('data-ch'), 10);
|
||
if (ch >= 10000 && ch <= 60000) {
|
||
mediaRestoreFromCache(msgID);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Relay slot indices — match protocol/media.go.
|
||
var RELAY_DNS = 0, RELAY_GITHUB = 1;
|
||
|
||
function parseDownloadableMedia(text, tag) {
|
||
var rest = text.substring(tag.length);
|
||
var nl = rest.indexOf('\n');
|
||
var head = nl >= 0 ? rest.substring(0, nl) : rest;
|
||
var caption = nl >= 0 ? rest.substring(nl + 1) : '';
|
||
head = head.trim();
|
||
var empty = { downloadable: false, dnsAvailable: false, githubAvailable: false, relays: [], size: 0, channel: 0, blocks: 0, crc: '', filename: '', caption: caption };
|
||
if (!head) return empty;
|
||
var parts = head.split(':');
|
||
if (parts.length < 5) {
|
||
empty.caption = rest.replace(/^\n/, '');
|
||
return empty;
|
||
}
|
||
var size = parseInt(parts[0], 10);
|
||
// parts[1] is a comma-separated relay flag list, e.g. "1,0".
|
||
var flagParts = parts[1].split(',');
|
||
var relays = [];
|
||
var flagsOK = true;
|
||
for (var fi = 0; fi < flagParts.length; fi++) {
|
||
var f = flagParts[fi].trim();
|
||
if (f !== '0' && f !== '1') { flagsOK = false; break; }
|
||
relays.push(f === '1');
|
||
}
|
||
if (!flagsOK) {
|
||
empty.caption = rest.replace(/^\n/, '');
|
||
return empty;
|
||
}
|
||
var ch = parseInt(parts[2], 10);
|
||
var blk = parseInt(parts[3], 10);
|
||
var crc = parts[4].toLowerCase();
|
||
if (isNaN(size) || isNaN(ch) || isNaN(blk) || !/^[0-9a-f]+$/.test(crc)) {
|
||
empty.caption = rest.replace(/^\n/, '');
|
||
return empty;
|
||
}
|
||
var filename = parts.length >= 6 ? parts.slice(5).join(':') : '';
|
||
var dnsOK = !!relays[RELAY_DNS] && ch >= 10000 && ch <= 60000 && blk > 0;
|
||
var ghOK = !!relays[RELAY_GITHUB];
|
||
return {
|
||
downloadable: dnsOK || ghOK,
|
||
dnsAvailable: dnsOK,
|
||
githubAvailable: ghOK,
|
||
relays: relays,
|
||
size: size,
|
||
channel: ch,
|
||
blocks: blk,
|
||
crc: crc,
|
||
filename: filename,
|
||
caption: caption
|
||
};
|
||
}
|
||
|
||
function formatBytes(n) {
|
||
if (!n || n < 0) return '0 B';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function mediaTagLabel(tag) {
|
||
return tag.replace(/^\[|\]$/g, '');
|
||
}
|
||
|
||
function mediaIsImageTag(tag) {
|
||
return tag === '[IMAGE]' || tag === '[STICKER]' || tag === '[GIF]';
|
||
}
|
||
|
||
function mediaIsPlayableTag(tag) {
|
||
return tag === '[VIDEO]' || tag === '[AUDIO]' || tag === '[VOICE]';
|
||
}
|
||
|
||
function mediaExtForTag(tag, mime) {
|
||
if (mime) {
|
||
if (/^image\/(jpeg|jpg)$/i.test(mime)) return 'jpg';
|
||
if (/^image\/png$/i.test(mime)) return 'png';
|
||
if (/^image\/webp$/i.test(mime)) return 'webp';
|
||
if (/^image\/gif$/i.test(mime)) return 'gif';
|
||
if (/^video\/mp4$/i.test(mime)) return 'mp4';
|
||
if (/^audio\/(mpeg|mp3)$/i.test(mime)) return 'mp3';
|
||
if (/^audio\/ogg$/i.test(mime)) return 'ogg';
|
||
if (/^application\/pdf$/i.test(mime)) return 'pdf';
|
||
}
|
||
switch (tag) {
|
||
case '[IMAGE]': return 'jpg';
|
||
case '[STICKER]': return 'webp';
|
||
case '[GIF]': return 'gif';
|
||
case '[VIDEO]': return 'mp4';
|
||
case '[AUDIO]': return 'mp3';
|
||
}
|
||
return 'bin';
|
||
}
|
||
|
||
function mediaSummaryLine(tag, parsed) {
|
||
var label = mediaTagLabel(tag);
|
||
var parts = [label];
|
||
if (parsed.size > 0) parts.push(formatBytes(parsed.size));
|
||
if (parsed.blocks > 0) parts.push(parsed.blocks + ' ' + (t('blocks_label') || 'blocks'));
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function renderDownloadableMedia(tag, parsed, msgID) {
|
||
var label = mediaTagLabel(tag);
|
||
var summary = mediaSummaryLine(tag, parsed);
|
||
var sizeStr = parsed.size > 0 ? formatBytes(parsed.size) : '?';
|
||
var canDownload = parsed.downloadable;
|
||
var domID = 'media-' + msgID;
|
||
var dataAttrs = 'id="' + domID + '" data-tag="' + escAttr(tag)
|
||
+ '" data-ch="' + parsed.channel + '" data-blk="' + parsed.blocks
|
||
+ '" data-size="' + parsed.size + '" data-crc="' + parsed.crc
|
||
+ '" data-dns="' + (parsed.dnsAvailable ? '1' : '0') + '"'
|
||
+ ' data-gh="' + (parsed.githubAvailable ? '1' : '0') + '"'
|
||
+ ' data-fname="' + escAttr(parsed.filename || '') + '" data-msg="' + msgID + '"';
|
||
|
||
if (mediaIsImageTag(tag)) {
|
||
var imageInner;
|
||
if (canDownload) {
|
||
imageInner = '<button class="media-action" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('download') || 'Download') + '">'
|
||
+ '<span class="media-icon">⬇</span>'
|
||
+ '<span class="media-meta" dir="ltr">' + esc(summary) + '</span>'
|
||
+ '</button>';
|
||
} else {
|
||
imageInner = '<button class="media-action media-action-blocked" type="button" onclick="mediaShowBlockedReason(\'' + domID + '\')" title="' + esc(t('media_blocked_title') || 'Not available for download') + '">'
|
||
+ '<span class="media-icon media-icon-blocked">✕</span>'
|
||
+ '<span class="media-meta" dir="ltr">' + esc(summary) + '</span>'
|
||
+ '</button>';
|
||
}
|
||
return '<div class="media-card media-image" ' + dataAttrs + '>'
|
||
+ '<div class="media-image-preview" id="' + domID + '-preview">'
|
||
+ imageInner
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
// File-style row: tag chip + filename(label) + size + action button
|
||
var btnHTML = canDownload
|
||
? '<button class="media-file-btn" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('download') || 'Download') + '">⬇</button>'
|
||
: '<button class="media-file-btn media-file-btn-blocked" onclick="mediaShowBlockedReason(\'' + domID + '\')" type="button" title="' + esc(t('media_blocked_title') || 'Not available for download') + '">✕</button>';
|
||
var detail = (parsed.size > 0 ? formatBytes(parsed.size) : '?')
|
||
+ (parsed.blocks > 0 ? ' · ' + parsed.blocks + ' ' + (t('blocks_label') || 'blocks') : '');
|
||
var displayName = parsed.filename || label;
|
||
return '<div class="media-card media-file" ' + dataAttrs + '>'
|
||
+ '<div class="media-file-icon">' + label.charAt(0) + '</div>'
|
||
+ '<div class="media-file-info">'
|
||
+ '<div class="media-file-name" dir="ltr">' + esc(displayName) + '</div>'
|
||
+ '<div class="media-file-size" id="' + domID + '-text" dir="ltr">' + detail + '</div>'
|
||
+ '<div class="media-progress-bar media-progress-bar-thin"><div class="media-progress-fill" id="' + domID + '-fill" style="width:0%"></div></div>'
|
||
+ '</div>'
|
||
+ '<div class="media-file-actions" id="' + domID + '-actions">' + btnHTML + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function mediaShowBlockedReason(domID) {
|
||
var card = document.getElementById(domID);
|
||
if (!card) return;
|
||
var size = parseInt(card.getAttribute('data-size'), 10) || 0;
|
||
var maxSize = (window._serverMediaMaxBytes || 0);
|
||
var msg;
|
||
if (maxSize > 0 && size > maxSize) {
|
||
msg = (t('media_blocked_too_large') || 'This file is larger than the server\'s configured cache limit ({size} > {limit}).')
|
||
.replace('{size}', formatBytes(size))
|
||
.replace('{limit}', formatBytes(maxSize));
|
||
} else if (maxSize > 0) {
|
||
msg = (t('media_blocked_unavailable') || 'The server didn\'t cache this file. It may exceed the {limit} cache limit, or the upstream fetch failed.')
|
||
.replace('{limit}', formatBytes(maxSize));
|
||
} else {
|
||
msg = t('media_blocked_generic') || 'The server didn\'t cache this file.';
|
||
}
|
||
showInfoDialog(msg);
|
||
}
|
||
|
||
async function mediaStart(domID) {
|
||
var card = document.getElementById(domID);
|
||
if (!card) return;
|
||
var msgID = card.getAttribute('data-msg');
|
||
if (mediaBlobURLs[msgID]) {
|
||
mediaShowBlob(card, mediaBlobURLs[msgID]);
|
||
return;
|
||
}
|
||
if (mediaInflight[msgID]) {
|
||
var ok = await showConfirmDialog(t('cancel_media_msg') || 'Cancel this download?', t('yes') || 'Yes', t('no') || 'No');
|
||
if (!ok) return;
|
||
if (!mediaInflight[msgID]) return;
|
||
try {
|
||
var inf = mediaInflight[msgID];
|
||
if (inf.ctrl) inf.ctrl.abort();
|
||
else if (inf.xhr) inf.xhr.abort();
|
||
} catch (e) { }
|
||
delete mediaInflight[msgID];
|
||
mediaResetCard(card);
|
||
return;
|
||
}
|
||
if (mediaQueued[domID]) {
|
||
var ok2 = await showConfirmDialog(t('cancel_media_msg') || 'Cancel this download?', t('yes') || 'Yes', t('no') || 'No');
|
||
if (!ok2) return;
|
||
mediaDequeue(domID);
|
||
mediaResetCard(card);
|
||
return;
|
||
}
|
||
if (mediaActiveCount >= MEDIA_MAX_CONCURRENT) {
|
||
mediaQueued[domID] = true;
|
||
mediaQueue.push({ domID: domID, run: function () { mediaRunDownload(domID); } });
|
||
mediaShowQueued(card);
|
||
return;
|
||
}
|
||
mediaRunDownload(domID);
|
||
}
|
||
|
||
function mediaDequeue(domID) {
|
||
delete mediaQueued[domID];
|
||
for (var i = 0; i < mediaQueue.length; i++) {
|
||
if (mediaQueue[i].domID === domID) {
|
||
mediaQueue.splice(i, 1);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function mediaPumpQueue() {
|
||
while (mediaActiveCount < MEDIA_MAX_CONCURRENT && mediaQueue.length > 0) {
|
||
var next = mediaQueue.shift();
|
||
delete mediaQueued[next.domID];
|
||
next.run();
|
||
}
|
||
}
|
||
|
||
function mediaShowQueued(card) {
|
||
var domID = card.id;
|
||
var label = t('queued') || 'Queued';
|
||
if (mediaIsImageTag(card.getAttribute('data-tag'))) {
|
||
var preview = document.getElementById(domID + '-preview');
|
||
if (preview) {
|
||
preview.innerHTML = '<button class="media-action media-action-queued" type="button" onclick="mediaStart(\'' + domID + '\')" title="' + esc(t('cancel') || 'Cancel') + '">'
|
||
+ '<span class="media-icon">⏳</span>'
|
||
+ '<span class="media-meta" dir="ltr">' + esc(label) + '</span>'
|
||
+ '</button>';
|
||
}
|
||
} else {
|
||
var actions = document.getElementById(domID + '-actions');
|
||
if (actions) actions.innerHTML = '<button class="media-file-btn media-file-btn-queued" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(label) + '">⏳</button>';
|
||
}
|
||
}
|
||
|
||
// GH_MAX_ATTEMPTS = total tries (initial + retries). 2 = 1 initial + 1 retry.
|
||
// 404 / 429 skip retry entirely — file genuinely missing or rate-limited.
|
||
var GH_MAX_ATTEMPTS = 2, GH_RETRY_DELAY_MS = 500;
|
||
|
||
async function mediaRunDownload(domID, opts) {
|
||
opts = opts || {};
|
||
var card = document.getElementById(domID);
|
||
if (!card) { mediaPumpQueue(); return; }
|
||
var msgID = card.getAttribute('data-msg');
|
||
var ch = card.getAttribute('data-ch');
|
||
var blk = card.getAttribute('data-blk');
|
||
var size = card.getAttribute('data-size');
|
||
var crc = card.getAttribute('data-crc');
|
||
var fname = card.getAttribute('data-fname') || '';
|
||
var ghAvail = card.getAttribute('data-gh') === '1';
|
||
var dnsAvail = card.getAttribute('data-dns') === '1';
|
||
var source = opts.forceSource || (ghAvail ? 'fast' : 'slow');
|
||
if (!opts.forceSource && source === 'slow' && dnsAvail) {
|
||
var ok = await showConfirmDialog(
|
||
t('media_slow_only') || 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
|
||
t('yes') || 'Yes', t('no') || 'No');
|
||
if (!ok) { mediaPumpQueue(); return; }
|
||
}
|
||
var baseUrl = '/api/media/get?ch=' + encodeURIComponent(ch)
|
||
+ '&blk=' + encodeURIComponent(blk)
|
||
+ '&size=' + encodeURIComponent(size)
|
||
+ '&crc=' + encodeURIComponent(crc)
|
||
+ (fname ? '&name=' + encodeURIComponent(fname) : '');
|
||
var url = baseUrl + '&source=' + source;
|
||
mediaActiveCount++;
|
||
var attempt = 0;
|
||
debugLog('media: download msg=' + msgID + ' source=' + source + ' size=' + size);
|
||
|
||
var ctrl = new AbortController();
|
||
var pollTimer = null;
|
||
var progressShownAt = 0;
|
||
var MIN_PROGRESS_VISIBLE_MS = 350;
|
||
|
||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||
function finishSlot() {
|
||
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
|
||
mediaPumpQueue();
|
||
}
|
||
function restartWith(newSource) {
|
||
stopPoll();
|
||
try { ctrl.abort(); } catch (e) { }
|
||
delete mediaInflight[msgID];
|
||
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
|
||
delete mediaProgressState[msgID];
|
||
mediaRunDownload(domID, { forceSource: newSource });
|
||
}
|
||
async function handleRateLimit(resetMin) {
|
||
var minutes = (resetMin && resetMin > 0) ? resetMin : '?';
|
||
showToast((t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes));
|
||
restartWith('slow');
|
||
}
|
||
async function handleFastFailure(reason, status) {
|
||
attempt++;
|
||
var skipRetry = (status === 404 || status === 429);
|
||
if (!skipRetry && attempt < GH_MAX_ATTEMPTS) {
|
||
await new Promise(function (r) { setTimeout(r, GH_RETRY_DELAY_MS); });
|
||
runOnce();
|
||
return;
|
||
}
|
||
if (skipRetry && source === 'fast') {
|
||
var note = (status === 404)
|
||
? (t('media_relay_404_fallback') || 'Not in fast relay yet — switching to DNS')
|
||
: (t('media_rate_limited_fallback') || 'Rate limit hit — switching to DNS');
|
||
showToast(note);
|
||
restartWith('slow');
|
||
return;
|
||
}
|
||
if (source === 'fast' && dnsAvail) {
|
||
var ok2 = await showConfirmDialog(
|
||
t('media_relay_fallback') || "Fast relay failed. Try the slow DNS path? Note: this can be very slow.",
|
||
t('yes') || 'Yes', t('no') || 'No');
|
||
if (ok2) { restartWith('slow'); return; }
|
||
}
|
||
mediaShowError(card, reason || (t('media_failed') || 'Download failed'));
|
||
finishSlot();
|
||
}
|
||
|
||
async function deliverBlob(blob, headers) {
|
||
var expectedCRC = parseInt(crc, 16);
|
||
var expectedSize = parseInt(size, 10);
|
||
if (blob && !isNaN(expectedSize) && expectedSize > 0 && blob.size !== expectedSize) {
|
||
await mediaForgetCache(card);
|
||
if (source === 'fast') { handleFastFailure(t('media_size_mismatch') || 'Size mismatch'); return; }
|
||
mediaShowError(card, t('media_size_mismatch') || 'Size mismatch');
|
||
return;
|
||
}
|
||
if (!isNaN(expectedCRC) && expectedCRC > 0) {
|
||
try {
|
||
var got = await blobCRC32(blob);
|
||
if (got !== expectedCRC) {
|
||
await mediaForgetCache(card);
|
||
if (source === 'fast') { handleFastFailure(t('media_hash_mismatch') || 'Content hash mismatch'); return; }
|
||
mediaShowError(card, t('media_hash_mismatch') || 'Content hash mismatch');
|
||
return;
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
var mime = (headers && headers.get('Content-Type')) || '';
|
||
var blobURL = URL.createObjectURL(blob);
|
||
mediaBlobURLs[msgID] = blobURL;
|
||
mediaBlobs[msgID] = { blob: blob, url: blobURL, mime: mime };
|
||
mediaShowBlob(card, blobURL);
|
||
mediaPersistBlob(msgID, card, blob, mime);
|
||
}
|
||
|
||
// fetch + manual reader so we can finalise the moment we have
|
||
// Content-Length bytes — some censoring proxies hold the
|
||
// connection open after sending the body, which makes XHR /
|
||
// resp.blob() hang at 100 %. Cancelling the reader breaks us
|
||
// out of that wait.
|
||
async function runOnce() {
|
||
try {
|
||
var resp = await fetch(url, { signal: ctrl.signal, cache: 'no-store' });
|
||
if (!resp.ok) {
|
||
stopPoll();
|
||
delete mediaInflight[msgID];
|
||
if (source === 'fast' && resp.status === 429) {
|
||
var resetMin = parseInt(resp.headers.get('X-Relay-Reset-Min') || '0', 10);
|
||
await handleRateLimit(resetMin);
|
||
return;
|
||
}
|
||
if (source === 'fast') { handleFastFailure(resp.statusText || ('HTTP ' + resp.status), resp.status); return; }
|
||
mediaShowError(card, resp.statusText || ('HTTP ' + resp.status));
|
||
finishSlot();
|
||
return;
|
||
}
|
||
var total = parseInt(resp.headers.get('Content-Length') || '0', 10) || (parseInt(size, 10) || 0);
|
||
var blob;
|
||
if (!resp.body || !resp.body.getReader) {
|
||
// Old WebView fallback — accept the original hang risk.
|
||
blob = await resp.blob();
|
||
} else {
|
||
var reader = resp.body.getReader();
|
||
var chunks = [];
|
||
var received = 0;
|
||
while (true) {
|
||
var step = await reader.read();
|
||
if (step.done) break;
|
||
chunks.push(step.value);
|
||
received += step.value.byteLength;
|
||
mediaUpdateProgress(card, received, total);
|
||
if (total > 0 && received >= total) {
|
||
try { await reader.cancel(); } catch (e) { }
|
||
break;
|
||
}
|
||
}
|
||
blob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'application/octet-stream' });
|
||
}
|
||
stopPoll();
|
||
delete mediaInflight[msgID];
|
||
var totalSize = blob.size || (parseInt(card.getAttribute('data-size'), 10) || 0);
|
||
if (totalSize > 0) mediaUpdateProgress(card, totalSize, totalSize);
|
||
debugLog('media: ok msg=' + msgID + ' source=' + source + ' served-by=' + (resp.headers.get('X-Cache') || '?'));
|
||
var elapsed = Date.now() - progressShownAt;
|
||
var run = function () { deliverBlob(blob, resp.headers).finally(finishSlot); };
|
||
if (progressShownAt > 0 && elapsed < MIN_PROGRESS_VISIBLE_MS) {
|
||
setTimeout(run, MIN_PROGRESS_VISIBLE_MS - elapsed);
|
||
} else {
|
||
run();
|
||
}
|
||
} catch (e) {
|
||
stopPoll();
|
||
delete mediaInflight[msgID];
|
||
if (e && e.name === 'AbortError') { finishSlot(); return; }
|
||
if (source === 'fast') { handleFastFailure((e && e.message) || 'network error'); return; }
|
||
mediaShowError(card, (e && e.message) || 'network error');
|
||
finishSlot();
|
||
}
|
||
}
|
||
|
||
mediaProgressState[msgID] = {
|
||
loaded: 0,
|
||
total: parseInt(size, 10) || 0,
|
||
completed: 0,
|
||
blocks: source === 'slow' ? (parseInt(blk, 10) || 0) : 0
|
||
};
|
||
|
||
var pollUrl = '/api/media/progress?ch=' + encodeURIComponent(ch)
|
||
+ '&blk=' + encodeURIComponent(blk)
|
||
+ '&crc=' + encodeURIComponent(crc);
|
||
pollTimer = setInterval(async function () {
|
||
try {
|
||
var pr = await fetch(pollUrl);
|
||
if (!pr.ok) return;
|
||
var pj = await pr.json();
|
||
if (pj.active === false) return;
|
||
mediaApplyBlockProgress(card, pj.completed | 0, pj.total | 0);
|
||
} catch (e) { }
|
||
}, 500);
|
||
|
||
mediaInflight[msgID] = { ctrl: ctrl };
|
||
mediaShowProgress(card);
|
||
progressShownAt = Date.now();
|
||
runOnce();
|
||
}
|
||
|
||
// mediaProgressState is the per-download counter. Both xhr.onprogress
|
||
// (bytes) and the /api/media/progress poll (blocks) write into it, but
|
||
// values only ever increase — so a poll that lags behind the byte
|
||
// stream never makes the bar jump backwards.
|
||
var mediaProgressState = {};
|
||
|
||
// mediaApplyBlockProgress updates the size text + fill from the polled
|
||
// block counter. Driven by /api/media/progress so the user gets real
|
||
// per-block updates, not byte-buffered estimates.
|
||
function mediaShowProgress(card) {
|
||
var domID = card.id;
|
||
var totalSize = parseInt(card.getAttribute('data-size'), 10) || 0;
|
||
var blocks = parseInt(card.getAttribute('data-blk'), 10) || 0;
|
||
var blocksSuffix = blocks > 0 ? ' · 0/' + blocks + ' ' + (t('blocks_label') || 'blocks') : '';
|
||
var initialText = (totalSize > 0 ? '0 / ' + formatBytes(totalSize) : (t('downloading') || 'Downloading...')) + blocksSuffix;
|
||
card.classList.add('downloading');
|
||
if (mediaIsImageTag(card.getAttribute('data-tag'))) {
|
||
var preview = document.getElementById(domID + '-preview');
|
||
if (preview) {
|
||
preview.innerHTML = '<div class="media-progress-overlay" id="' + domID + '-progress">'
|
||
+ '<div class="media-progress-bar"><div class="media-progress-fill" id="' + domID + '-fill" style="width:0%"></div></div>'
|
||
+ '<div class="media-progress-text" id="' + domID + '-text" dir="ltr">' + initialText + '</div>'
|
||
+ '<button class="media-cancel-btn" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('cancel') || 'Cancel') + '">×</button>'
|
||
+ '</div>';
|
||
}
|
||
var msgIDImg = card.getAttribute('data-msg');
|
||
if (msgIDImg && mediaProgressState[msgIDImg]) mediaRenderProgressState(card);
|
||
return;
|
||
}
|
||
// File row: rebuild the info column from scratch so the fill always
|
||
// starts at 0% (clears stale 100% from a previous successful download)
|
||
// and the cancel button replaces the download button.
|
||
var label = mediaTagLabel(card.getAttribute('data-tag'));
|
||
var displayName = card.getAttribute('data-fname') || label;
|
||
var newInner = '<div class="media-file-icon">' + label.charAt(0) + '</div>'
|
||
+ '<div class="media-file-info">'
|
||
+ '<div class="media-file-name" dir="ltr">' + esc(displayName) + '</div>'
|
||
+ '<div class="media-file-size" id="' + domID + '-text" dir="ltr">' + initialText + '</div>'
|
||
+ '<div class="media-progress-bar media-progress-bar-thin"><div class="media-progress-fill" id="' + domID + '-fill" style="width:0%"></div></div>'
|
||
+ '</div>'
|
||
+ '<div class="media-file-actions" id="' + domID + '-actions">'
|
||
+ '<button class="media-file-btn media-file-btn-cancel" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('cancel') || 'Cancel') + '">×</button>'
|
||
+ '</div>';
|
||
card.innerHTML = newInner;
|
||
var msgID = card.getAttribute('data-msg');
|
||
if (msgID && mediaProgressState[msgID]) mediaRenderProgressState(card);
|
||
}
|
||
|
||
// Byte-level update from xhr.onprogress.
|
||
function mediaUpdateProgress(card, loaded, total) {
|
||
if (!total || total <= 0) {
|
||
total = parseInt(card.getAttribute('data-size'), 10) || 0;
|
||
}
|
||
var msgID = card.getAttribute('data-msg');
|
||
var s = mediaProgressState[msgID] || { loaded: 0, total: 0, completed: 0, blocks: parseInt(card.getAttribute('data-blk'), 10) || 0 };
|
||
if (loaded > s.loaded) s.loaded = loaded;
|
||
if (total > s.total) s.total = total;
|
||
mediaProgressState[msgID] = s;
|
||
mediaRenderProgressState(card);
|
||
}
|
||
|
||
// Block-level update from /api/media/progress poll.
|
||
function mediaApplyBlockProgress(card, completed, total) {
|
||
if (!total || total <= 0) return;
|
||
if (completed < 0) completed = 0;
|
||
if (completed > total) completed = total;
|
||
var msgID = card.getAttribute('data-msg');
|
||
var s = mediaProgressState[msgID] || { loaded: 0, total: parseInt(card.getAttribute('data-size'), 10) || 0, completed: 0, blocks: 0 };
|
||
if (completed > s.completed) s.completed = completed;
|
||
if (total > s.blocks) s.blocks = total;
|
||
mediaProgressState[msgID] = s;
|
||
mediaRenderProgressState(card);
|
||
}
|
||
|
||
// Render the combined "X / Y (P%) · K/N blocks" string from monotonic
|
||
// state so the two update sources never fight each other.
|
||
function mediaRenderProgressState(card) {
|
||
var msgID = card.getAttribute('data-msg');
|
||
var s = mediaProgressState[msgID];
|
||
if (!s) return;
|
||
// Pick the higher of byte% and block% for the bar so it never goes
|
||
// backwards even if one side races ahead of the other.
|
||
var bytePct = s.total > 0 ? Math.min(100, Math.floor(s.loaded * 100 / s.total)) : 0;
|
||
var blockPct = s.blocks > 0 ? Math.min(100, Math.floor(s.completed * 100 / s.blocks)) : 0;
|
||
var pct = Math.max(bytePct, blockPct);
|
||
var fill = document.getElementById(card.id + '-fill');
|
||
if (fill) {
|
||
var prevPct = parseInt(fill.style.width, 10) || 0;
|
||
if (pct > prevPct) fill.style.width = pct + '%';
|
||
}
|
||
var txt = document.getElementById(card.id + '-text');
|
||
if (!txt) return;
|
||
var head = s.total > 0
|
||
? formatBytes(s.loaded) + ' / ' + formatBytes(s.total) + ' (' + pct + '%)'
|
||
: (s.loaded > 0 ? formatBytes(s.loaded) : (t('downloading') || 'Downloading...'));
|
||
var blocksSuffix = s.blocks > 0
|
||
? ' · ' + s.completed + '/' + s.blocks + ' ' + (t('blocks_label') || 'blocks')
|
||
: '';
|
||
txt.innerHTML = head + blocksSuffix;
|
||
}
|
||
|
||
function mediaShowBlob(card, blobURL) {
|
||
var tag = card.getAttribute('data-tag');
|
||
var msgID = card.getAttribute('data-msg');
|
||
var domID = card.id;
|
||
card.classList.remove('downloading');
|
||
var entry = mediaBlobs[msgID];
|
||
var mime = entry && entry.mime ? entry.mime.toLowerCase() : '';
|
||
var isInlineImage = /^image\/(jpeg|jpg|png|gif|webp|avif|bmp)$/.test(mime);
|
||
if (mediaIsImageTag(tag) && isInlineImage) {
|
||
var preview = document.getElementById(domID + '-preview');
|
||
if (!preview) return;
|
||
var alt = esc(mediaTagLabel(tag));
|
||
preview.innerHTML = '<img src="' + escAttr(blobURL) + '" alt="' + alt
|
||
+ '" class="media-image-loaded" onclick="mediaOpen(\'' + msgID + '\')"'
|
||
+ ' onerror="mediaImageFailed(\'' + msgID + '\')">'
|
||
+ '<div class="media-image-actions">' + buildMediaActions(msgID, tag) + '</div>';
|
||
return;
|
||
}
|
||
if (mediaIsImageTag(tag)) {
|
||
mediaConvertImageToFile(card);
|
||
}
|
||
var actions = document.getElementById(domID + '-actions');
|
||
if (actions) actions.innerHTML = buildMediaActions(msgID, tag);
|
||
var fill = document.getElementById(domID + '-fill');
|
||
if (fill) fill.style.width = '100%';
|
||
var txt = document.getElementById(domID + '-text');
|
||
if (txt) txt.textContent = formatBytes(parseInt(card.getAttribute('data-size'), 10) || 0) + ' ✓';
|
||
}
|
||
|
||
function mediaImageFailed(msgID) {
|
||
var card = document.getElementById('media-' + msgID);
|
||
if (!card) return;
|
||
mediaConvertImageToFile(card);
|
||
var actions = document.getElementById(card.id + '-actions');
|
||
if (actions) actions.innerHTML = buildMediaActions(msgID, card.getAttribute('data-tag'));
|
||
}
|
||
|
||
function mediaConvertImageToFile(card) {
|
||
var tag = card.getAttribute('data-tag');
|
||
var domID = card.id;
|
||
var size = parseInt(card.getAttribute('data-size'), 10) || 0;
|
||
var blocks = parseInt(card.getAttribute('data-blk'), 10) || 0;
|
||
var label = mediaTagLabel(tag);
|
||
var detail = (size > 0 ? formatBytes(size) : '?')
|
||
+ (blocks > 0 ? ' · ' + blocks + ' ' + (t('blocks_label') || 'blocks') : '');
|
||
card.classList.remove('media-image');
|
||
card.classList.add('media-file');
|
||
card.innerHTML = '<div class="media-file-icon">' + label.charAt(0) + '</div>'
|
||
+ '<div class="media-file-info">'
|
||
+ '<div class="media-file-name">' + label + '</div>'
|
||
+ '<div class="media-file-size" id="' + domID + '-text" dir="ltr">' + detail + '</div>'
|
||
+ '<div class="media-progress-bar media-progress-bar-thin"><div class="media-progress-fill" id="' + domID + '-fill" style="width:100%"></div></div>'
|
||
+ '</div>'
|
||
+ '<div class="media-file-actions" id="' + domID + '-actions"></div>';
|
||
}
|
||
|
||
// Read window.Android once. The native bridge is the WebView wrapper's
|
||
// way of getting around blob-URL / Web-Share limitations on Android.
|
||
var androidBridge = (typeof window !== 'undefined' && window.Android) ? window.Android : null;
|
||
|
||
// Single entry point invoked by MainActivity.kt's back-press handler.
|
||
// Returns nothing — the JS side resolves the action itself, calling
|
||
// back into the bridge for "minimize"/"kill" if the user picks one
|
||
// of those from the close-confirmation dialog.
|
||
window.handleAndroidBack = async function () {
|
||
if (closeMediaLightbox()) return;
|
||
// Telemirror has its own full-screen modal (.tm-modal, not
|
||
// .modal-overlay). Drawer first, then modal, then anything else.
|
||
var tmModal = document.getElementById('telemirrorModal');
|
||
if (tmModal && tmModal.classList.contains('active')) {
|
||
var tmSb = document.getElementById('tmSidebar');
|
||
if (tmSb && tmSb.classList.contains('open')) {
|
||
tmSb.classList.remove('open');
|
||
return;
|
||
}
|
||
if (typeof closeTelemirror === 'function') closeTelemirror();
|
||
else tmModal.classList.remove('active');
|
||
return;
|
||
}
|
||
var openModal = document.querySelector('.modal-overlay.active');
|
||
if (openModal) { openModal.classList.remove('active'); return; }
|
||
if (mobileQuery.matches && document.getElementById('app').classList.contains('chat-open')) {
|
||
openSidebar();
|
||
return;
|
||
}
|
||
// At app root — confirm before closing/killing the process.
|
||
var choice = await showCloseConfirm();
|
||
if (choice === 'background' && androidBridge && androidBridge.minimizeApp) {
|
||
androidBridge.minimizeApp();
|
||
} else if (choice === 'kill' && androidBridge && androidBridge.killApp) {
|
||
androidBridge.killApp();
|
||
}
|
||
// 'cancel' or unknown → stay in the app.
|
||
};
|
||
|
||
function showCloseConfirm() {
|
||
return new Promise(function (resolve) {
|
||
var existing = document.getElementById('closeConfirmModal');
|
||
if (existing) existing.remove();
|
||
var overlay = document.createElement('div');
|
||
overlay.id = 'closeConfirmModal';
|
||
overlay.className = 'modal-overlay active';
|
||
overlay.innerHTML =
|
||
'<div class="modal" style="max-width:380px">'
|
||
+ '<p style="font-size:14px;color:var(--text);margin-bottom:18px;line-height:1.6">'
|
||
+ esc(t('close_confirm') || 'Close thefeed?') + '</p>'
|
||
+ '<div class="modal-actions" style="flex-direction:column;gap:8px">'
|
||
+ '<button class="btn btn-flat" id="closeCancelBtn">' + esc(t('close_cancel') || "Don't close") + '</button>'
|
||
+ '<button class="btn btn-flat" id="closeBgBtn">' + esc(t('close_background') || 'Close, keep running in background') + '</button>'
|
||
+ '<button class="btn btn-primary" id="closeKillBtn" style="background:var(--danger,#e74c3c)">' + esc(t('close_kill') || 'Close and stop service') + '</button>'
|
||
+ '</div></div>';
|
||
document.body.appendChild(overlay);
|
||
var done = function (choice) {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
resolve(choice);
|
||
};
|
||
document.getElementById('closeCancelBtn').onclick = function () { done('cancel'); };
|
||
document.getElementById('closeBgBtn').onclick = function () { done('background'); };
|
||
document.getElementById('closeKillBtn').onclick = function () { done('kill'); };
|
||
});
|
||
}
|
||
|
||
function blobToBase64(blob) {
|
||
return new Promise(function (resolve, reject) {
|
||
var fr = new FileReader();
|
||
fr.onload = function () {
|
||
var result = fr.result || '';
|
||
var idx = result.indexOf(',');
|
||
resolve(idx >= 0 ? result.substring(idx + 1) : result);
|
||
};
|
||
fr.onerror = function () { reject(fr.error); };
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
function buildMediaActions(msgID, tag) {
|
||
var isImage = mediaIsImageTag(tag);
|
||
var isPlayable = mediaIsPlayableTag(tag);
|
||
var canShare = !!androidBridge || !!(navigator.share && navigator.canShare);
|
||
var openTitle = esc(t('media_open') || 'Open');
|
||
var playTitle = esc(t('media_play') || 'Play');
|
||
var saveTitle = esc(t('media_save') || 'Save');
|
||
var shareTitle = esc(t('media_share') || 'Share');
|
||
var html = '';
|
||
if (isPlayable) {
|
||
// Play icon (▶) — on Android Intent.createChooser picks the
|
||
// best system video/audio player; on browser the blob opens in a
|
||
// new tab where the browser's native viewer handles it.
|
||
html += '<button class="media-action-icon" onclick="mediaOpen(\'' + msgID + '\')" title="' + playTitle + '" aria-label="' + playTitle + '">▶</button>';
|
||
} else if (!isImage) {
|
||
html += '<button class="media-action-icon" onclick="mediaOpen(\'' + msgID + '\')" title="' + openTitle + '" aria-label="' + openTitle + '">🔗</button>';
|
||
}
|
||
html += '<button class="media-action-icon" onclick="mediaSave(\'' + msgID + '\')" title="' + saveTitle + '" aria-label="' + saveTitle + '">💾</button>';
|
||
if (canShare) {
|
||
html += '<button class="media-action-icon" onclick="mediaShare(\'' + msgID + '\')" title="' + shareTitle + '" aria-label="' + shareTitle + '">➦</button>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
async function mediaOpen(msgID) {
|
||
var entry = mediaBlobs[msgID];
|
||
if (!entry) return;
|
||
var card = document.getElementById('media-' + msgID);
|
||
var tag = card ? card.getAttribute('data-tag') : '';
|
||
if (mediaIsImageTag(tag) && entry.mime && /^image\//i.test(entry.mime)) {
|
||
showImageLightbox(entry.url, mediaTagLabel(tag));
|
||
return;
|
||
}
|
||
if (mediaIsPlayableTag(tag)) {
|
||
showMediaPlayer(entry, tag);
|
||
return;
|
||
}
|
||
if (androidBridge && androidBridge.openMedia) {
|
||
var fname = mediaFilenameFor(msgID, tag, entry.mime);
|
||
try {
|
||
var b64 = await blobToBase64(entry.blob);
|
||
androidBridge.openMedia(b64, entry.mime || 'application/octet-stream', fname);
|
||
} catch (e) { }
|
||
return;
|
||
}
|
||
try {
|
||
var a = document.createElement('a');
|
||
a.href = entry.url;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
} catch (e) { }
|
||
}
|
||
|
||
function showMediaPlayer(entry, tag) {
|
||
var existing = document.getElementById('mediaLightbox');
|
||
if (existing) existing.remove();
|
||
var isAudio = (tag === '[AUDIO]' || tag === '[VOICE]');
|
||
var mime = entry.mime || (isAudio ? 'audio/mpeg' : 'video/mp4');
|
||
var element = isAudio
|
||
? '<audio class="media-lightbox-audio" controls autoplay src="' + escAttr(entry.url) + '" type="' + escAttr(mime) + '"></audio>'
|
||
: '<video class="media-lightbox-video" controls autoplay playsinline src="' + escAttr(entry.url) + '" type="' + escAttr(mime) + '"></video>';
|
||
var overlay = document.createElement('div');
|
||
overlay.id = 'mediaLightbox';
|
||
overlay.className = 'media-lightbox';
|
||
overlay.innerHTML =
|
||
'<button class="media-lightbox-speed" type="button" aria-label="Playback speed">1x</button>'
|
||
+ '<button class="media-lightbox-close" type="button" aria-label="' + esc(t('close') || 'Close') + '">×</button>'
|
||
+ element;
|
||
var close = function () {
|
||
overlay.removeEventListener('click', onClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
var media = overlay.querySelector('audio,video');
|
||
if (media) { try { media.pause(); } catch (e) { } }
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
mediaLightboxCloser = null;
|
||
};
|
||
var onClick = function (e) {
|
||
if (e.target === overlay || (e.target.classList && e.target.classList.contains('media-lightbox-close'))) {
|
||
close();
|
||
}
|
||
};
|
||
var onKey = function (e) { if (e.key === 'Escape') close(); };
|
||
overlay.addEventListener('click', onClick);
|
||
document.addEventListener('keydown', onKey);
|
||
document.body.appendChild(overlay);
|
||
mediaLightboxCloser = close;
|
||
|
||
// Playback-speed cycling. Standard HTML5 playbackRate — supported
|
||
// on every modern WebView, no native bridge needed. Pitch is
|
||
// preserved by default in Chromium / WebKit (preservesPitch=true).
|
||
var speeds = [1, 1.25, 1.5, 2];
|
||
var saved = parseFloat(localStorage.getItem('thefeed_play_speed'));
|
||
var idx = speeds.indexOf(saved);
|
||
if (idx < 0) idx = 0;
|
||
var media = overlay.querySelector('audio,video');
|
||
var speedBtn = overlay.querySelector('.media-lightbox-speed');
|
||
function applySpeed() {
|
||
var s = speeds[idx];
|
||
if (media) {
|
||
try { media.playbackRate = s; } catch (e) { }
|
||
// Webkit needs the prefixed flag for pitch preservation.
|
||
try { media.preservesPitch = true; } catch (e) { }
|
||
try { media.webkitPreservesPitch = true; } catch (e) { }
|
||
}
|
||
if (speedBtn) speedBtn.textContent = (s === Math.floor(s) ? s : s.toFixed(2).replace(/0$/, '')) + 'x';
|
||
localStorage.setItem('thefeed_play_speed', String(s));
|
||
}
|
||
// Re-apply on every fresh `loadeddata` — some WebViews reset the
|
||
// rate when the source becomes ready.
|
||
if (media) media.addEventListener('loadeddata', applySpeed);
|
||
applySpeed();
|
||
if (speedBtn) {
|
||
// Coalesce rapid duplicate clicks. Some Android WebView builds
|
||
// fire click twice per tap (touchend + synthesized 300ms tap),
|
||
// which made one fast tap skip a step (1 → 1.5). 250 ms is well
|
||
// under any human double-tap interval.
|
||
var lastClickAt = 0;
|
||
speedBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
var now = Date.now();
|
||
if (now - lastClickAt < 250) return;
|
||
lastClickAt = now;
|
||
idx = (idx + 1) % speeds.length;
|
||
applySpeed();
|
||
});
|
||
}
|
||
}
|
||
|
||
function mediaFilenameFor(msgID, tag, mime) {
|
||
var card = document.getElementById('media-' + msgID);
|
||
var fname = card ? (card.getAttribute('data-fname') || '') : '';
|
||
if (!fname) {
|
||
var ext = mediaExtForTag(tag, mime);
|
||
fname = (mediaTagLabel(tag || 'FILE') + '-' + msgID + '.' + ext).toLowerCase();
|
||
}
|
||
return fname;
|
||
}
|
||
|
||
// Tracked close handler so the Android back button (and any other
|
||
// place outside the lightbox) can dismiss it cleanly.
|
||
var mediaLightboxCloser = null;
|
||
|
||
function closeMediaLightbox() {
|
||
if (mediaLightboxCloser) {
|
||
try { mediaLightboxCloser(); } catch (e) { }
|
||
mediaLightboxCloser = null;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function showImageLightbox(blobURL, alt) {
|
||
var existing = document.getElementById('mediaLightbox');
|
||
if (existing) existing.remove();
|
||
var overlay = document.createElement('div');
|
||
overlay.id = 'mediaLightbox';
|
||
overlay.className = 'media-lightbox';
|
||
overlay.innerHTML = '<button class="media-lightbox-close" type="button" aria-label="' + esc(t('close') || 'Close') + '">×</button>'
|
||
+ '<img class="media-lightbox-img" src="' + escAttr(blobURL) + '" alt="' + esc(alt || '') + '">';
|
||
var close = function () {
|
||
overlay.removeEventListener('click', onClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
mediaLightboxCloser = null;
|
||
};
|
||
var onClick = function (e) {
|
||
// Don't dismiss while pinching/dragging — the click event fires
|
||
// on touchend after a multi-touch zoom and would close the box.
|
||
if (zoomBusy) return;
|
||
if (e.target === overlay || (e.target.classList && e.target.classList.contains('media-lightbox-close'))) {
|
||
close();
|
||
}
|
||
};
|
||
var onKey = function (e) { if (e.key === 'Escape') close(); };
|
||
overlay.addEventListener('click', onClick);
|
||
document.addEventListener('keydown', onKey);
|
||
document.body.appendChild(overlay);
|
||
mediaLightboxCloser = close;
|
||
|
||
// Pinch-zoom + pan + double-tap on the image.
|
||
var imgEl = overlay.querySelector('.media-lightbox-img');
|
||
var zoomBusy = false;
|
||
attachPinchZoom(imgEl, function (busy) { zoomBusy = busy; });
|
||
}
|
||
|
||
// Two-finger pinch + drag-to-pan + double-tap toggle. Uses
|
||
// CSS transform so we don't need a library. busyCb fires true on
|
||
// multi-touch start and false a tick after touchend so the click
|
||
// close handler can ignore the trailing tap.
|
||
function attachPinchZoom(el, busyCb) {
|
||
var scale = 1, startScale = 1;
|
||
var translateX = 0, translateY = 0;
|
||
var startTX = 0, startTY = 0;
|
||
var pinchStartDist = 0;
|
||
var lastTap = 0;
|
||
var lastTouchEnd = 0;
|
||
el.style.touchAction = 'none';
|
||
el.style.transformOrigin = 'center center';
|
||
el.style.transition = 'transform 0.1s ease-out';
|
||
|
||
function apply() {
|
||
el.style.transform = 'translate(' + translateX + 'px, ' + translateY + 'px) scale(' + scale + ')';
|
||
}
|
||
function distance(t0, t1) {
|
||
var dx = t0.clientX - t1.clientX;
|
||
var dy = t0.clientY - t1.clientY;
|
||
return Math.hypot(dx, dy);
|
||
}
|
||
function clampPan() {
|
||
if (scale <= 1) { translateX = 0; translateY = 0; return; }
|
||
var maxX = (el.offsetWidth * (scale - 1)) / 2;
|
||
var maxY = (el.offsetHeight * (scale - 1)) / 2;
|
||
translateX = Math.max(-maxX, Math.min(maxX, translateX));
|
||
translateY = Math.max(-maxY, Math.min(maxY, translateY));
|
||
}
|
||
el.addEventListener('touchstart', function (e) {
|
||
if (e.touches.length === 2) {
|
||
pinchStartDist = distance(e.touches[0], e.touches[1]);
|
||
startScale = scale;
|
||
if (busyCb) busyCb(true);
|
||
} else if (e.touches.length === 1 && scale > 1) {
|
||
startTX = e.touches[0].clientX - translateX;
|
||
startTY = e.touches[0].clientY - translateY;
|
||
if (busyCb) busyCb(true);
|
||
}
|
||
}, { passive: true });
|
||
el.addEventListener('touchmove', function (e) {
|
||
if (e.touches.length === 2 && pinchStartDist > 0) {
|
||
e.preventDefault();
|
||
var d = distance(e.touches[0], e.touches[1]);
|
||
scale = Math.max(1, Math.min(5, startScale * d / pinchStartDist));
|
||
if (scale === 1) { translateX = 0; translateY = 0; }
|
||
apply();
|
||
} else if (e.touches.length === 1 && scale > 1) {
|
||
e.preventDefault();
|
||
translateX = e.touches[0].clientX - startTX;
|
||
translateY = e.touches[0].clientY - startTY;
|
||
clampPan();
|
||
apply();
|
||
}
|
||
}, { passive: false });
|
||
el.addEventListener('touchend', function (e) {
|
||
lastTouchEnd = Date.now();
|
||
setTimeout(function () { if (busyCb) busyCb(false); }, 50);
|
||
pinchStartDist = 0;
|
||
// 2→1 finger: re-seed pan origin so the leftover finger doesn't
|
||
// compute translation against stale startTX/startTY (=0) and snap
|
||
// the image off-screen.
|
||
if (e.touches.length === 1 && scale > 1) {
|
||
startTX = e.touches[0].clientX - translateX;
|
||
startTY = e.touches[0].clientY - translateY;
|
||
} else if (e.touches.length === 0) {
|
||
clampPan();
|
||
apply();
|
||
}
|
||
});
|
||
el.addEventListener('click', function (e) {
|
||
// Ignore clicks that immediately follow a pinch.
|
||
if (Date.now() - lastTouchEnd < 100) { e.stopPropagation(); return; }
|
||
var now = Date.now();
|
||
if (now - lastTap < 300) {
|
||
e.stopPropagation();
|
||
if (scale === 1) {
|
||
scale = 2;
|
||
} else {
|
||
scale = 1; translateX = 0; translateY = 0;
|
||
}
|
||
apply();
|
||
}
|
||
lastTap = now;
|
||
});
|
||
}
|
||
|
||
async function mediaSave(msgID) {
|
||
var entry = mediaBlobs[msgID];
|
||
if (!entry) return;
|
||
var card = document.getElementById('media-' + msgID);
|
||
var tag = card ? card.getAttribute('data-tag') : '';
|
||
var fname = mediaFilenameFor(msgID, tag, entry.mime);
|
||
if (androidBridge && androidBridge.saveMedia) {
|
||
try {
|
||
var b64 = await blobToBase64(entry.blob);
|
||
androidBridge.saveMedia(b64, entry.mime || 'application/octet-stream', fname);
|
||
} catch (e) { }
|
||
return;
|
||
}
|
||
var a = document.createElement('a');
|
||
a.href = entry.url;
|
||
a.download = fname;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
}
|
||
|
||
async function mediaShare(msgID) {
|
||
var entry = mediaBlobs[msgID];
|
||
if (!entry) return;
|
||
var card = document.getElementById('media-' + msgID);
|
||
var tag = card ? card.getAttribute('data-tag') : '';
|
||
var fname = mediaFilenameFor(msgID, tag, entry.mime);
|
||
if (androidBridge && androidBridge.shareMedia) {
|
||
try {
|
||
var b64 = await blobToBase64(entry.blob);
|
||
androidBridge.shareMedia(b64, entry.mime || 'application/octet-stream', fname);
|
||
} catch (e) { }
|
||
return;
|
||
}
|
||
var file;
|
||
try {
|
||
file = new File([entry.blob], fname, { type: entry.mime || 'application/octet-stream' });
|
||
} catch (e) {
|
||
mediaSave(msgID);
|
||
return;
|
||
}
|
||
var data = { files: [file], title: fname };
|
||
if (navigator.canShare && !navigator.canShare(data)) {
|
||
mediaSave(msgID);
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.share(data);
|
||
} catch (e) { }
|
||
}
|
||
|
||
function mediaShowError(card, msg) {
|
||
card.classList.remove('downloading');
|
||
var txt = document.getElementById(card.id + '-text');
|
||
if (txt) txt.textContent = (t('media_failed') || 'Download failed') + ': ' + msg;
|
||
var fill = document.getElementById(card.id + '-fill');
|
||
if (fill) fill.style.width = '0%';
|
||
}
|
||
|
||
function mediaResetCard(card) {
|
||
card.classList.remove('downloading');
|
||
var domID = card.id;
|
||
var msgID = card.getAttribute('data-msg');
|
||
var tag = card.getAttribute('data-tag');
|
||
var size = parseInt(card.getAttribute('data-size'), 10) || 0;
|
||
var sizeStr = size > 0 ? formatBytes(size) : '?';
|
||
var label = mediaTagLabel(tag);
|
||
if (mediaIsImageTag(tag)) {
|
||
var preview = document.getElementById(domID + '-preview');
|
||
if (preview) {
|
||
preview.innerHTML = '<button class="media-action" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('download') || 'Download') + '">'
|
||
+ '<span class="media-icon">⬇</span>'
|
||
+ '<span class="media-meta">' + label + ' · ' + sizeStr + '</span>'
|
||
+ '</button>';
|
||
}
|
||
return;
|
||
}
|
||
var actions = document.getElementById(domID + '-actions');
|
||
if (actions) {
|
||
actions.innerHTML = '<button class="media-file-btn" onclick="mediaStart(\'' + domID + '\')" type="button" title="' + esc(t('download') || 'Download') + '">⬇</button>';
|
||
}
|
||
var fill = document.getElementById(domID + '-fill');
|
||
if (fill) fill.style.width = '0%';
|
||
var txt = document.getElementById(domID + '-text');
|
||
if (txt) txt.textContent = sizeStr;
|
||
}
|
||
|
||
// Parses leading [IMAGE]/[VIDEO]/[FILE]/etc tags out of `text`.
|
||
// Returns {mediaHtml, textHtml} — used by both top-level messages
|
||
// and the body of a [REPLY] (so a reply that carries an image
|
||
// still renders a downloadable media card instead of dropping it).
|
||
function parseTextMedia(text, idBase) {
|
||
var iranify = function (s) {
|
||
return linkify(s).replace(/🇮🇷/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}☀️" style="height:1.1em;vertical-align:middle">');
|
||
};
|
||
var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[CONTACT]', '[LOCATION]'];
|
||
var rest = text;
|
||
var cards = [];
|
||
while (true) {
|
||
var matchedTag = null;
|
||
for (var m = 0; m < mediaTypes.length; m++) {
|
||
if (rest.indexOf(mediaTypes[m]) === 0) { matchedTag = mediaTypes[m]; break; }
|
||
}
|
||
if (!matchedTag) break;
|
||
var p = parseDownloadableMedia(rest, matchedTag);
|
||
// Bare "[TAG]<word>" with non-numeric/non-newline next char is
|
||
// caption text, not a media tag. Wire forms are "[TAG]\n..."
|
||
// or "[TAG]<digit>:<dl>:<ch>:<blk>:<crc>".
|
||
var afterTag = rest.charAt(matchedTag.length);
|
||
var isLegacyOrStructured = afterTag === '' || afterTag === '\n' || (afterTag >= '0' && afterTag <= '9');
|
||
if (!isLegacyOrStructured) break;
|
||
cards.push({ tag: matchedTag, parsed: p });
|
||
rest = p.caption;
|
||
}
|
||
var mediaHtml = '';
|
||
var textHtml = '';
|
||
if (cards.length === 0) {
|
||
textHtml = iranify(text);
|
||
} else if (cards.length === 1) {
|
||
var c0 = cards[0];
|
||
if (c0.parsed.downloadable || c0.parsed.size > 0) {
|
||
mediaHtml = renderDownloadableMedia(c0.tag, c0.parsed, idBase);
|
||
textHtml = c0.parsed.caption ? iranify(c0.parsed.caption) : '';
|
||
} else {
|
||
mediaHtml = '<div class="media-tag">' + c0.tag + '</div>';
|
||
textHtml = iranify(text.substring(c0.tag.length).replace(/^\n/, ''));
|
||
}
|
||
} else {
|
||
mediaHtml = '<div class="media-album">';
|
||
for (var k = 0; k < cards.length; k++) {
|
||
mediaHtml += renderDownloadableMedia(cards[k].tag, cards[k].parsed, idBase + '-' + k);
|
||
}
|
||
mediaHtml += '</div>';
|
||
textHtml = rest ? iranify(rest) : '';
|
||
}
|
||
return { mediaHtml: mediaHtml, textHtml: textHtml };
|
||
}
|
||
|
||
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 }
|
||
// Check if user is near the bottom before re-render (within 150px)
|
||
var wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
||
var prevScrollTop = el.scrollTop; // for restoring scroll on re-render mid-session
|
||
var isFirstRender = el.querySelector('.empty-state') !== null || el.querySelector('.msg') === null;
|
||
msgs.sort(function (a, b) { return (a.Timestamp || a.timestamp || 0) - (b.Timestamp || b.timestamp || 0) });
|
||
var html = '', lastDate = '';
|
||
// 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' };
|
||
var lastSeenTs = getLastSeenTimestamp(channelName(selectedChannel));
|
||
var isFirstVisit = lastSeenTs === 0;
|
||
var newMsgSepInserted = false;
|
||
var maxMsgID = 0;
|
||
var maxTimestamp = 0;
|
||
for (var i = 0; i < msgs.length; i++) {
|
||
var msg = msgs[i];
|
||
var id = msg.ID || msg.id;
|
||
var msgTs = msg.Timestamp || msg.timestamp || 0;
|
||
if (id > maxMsgID) maxMsgID = id;
|
||
if (msgTs > maxTimestamp) maxTimestamp = msgTs;
|
||
if (gapBefore[id]) {
|
||
html += '<div class="msg-gap-sep"><span>' + t('missed_messages').replace('{n}', gapBefore[id]) + '</span></div>';
|
||
}
|
||
// New messages separator (timestamp-based for X/Telegram compatibility)
|
||
if (!isFirstVisit && lastSeenTs > 0 && msgTs > lastSeenTs && !newMsgSepInserted) {
|
||
html += '<div class="msg-new-sep" id="newMsgSep"><span>' + t('new_messages') + '</span></div>';
|
||
newMsgSepInserted = true;
|
||
}
|
||
var ts = new Date((msg.Timestamp || msg.timestamp) * 1000);
|
||
var dateStr = ts.toLocaleDateString(dateLocale, dateOpts);
|
||
if (dateStr !== lastDate) { html += '<div class="msg-date-sep"><span dir="auto">' + dateStr + '</span></div>'; lastDate = dateStr }
|
||
var timeStr = ts.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit' });
|
||
var text = msg.Text || msg.text || '';
|
||
// Outgoing private-chat messages get a "[ME]\n" sentinel
|
||
// from the server. Strip it, flag the bubble for right-align
|
||
// and a [YOU] label, then continue parsing the rest.
|
||
var isOutgoing = false;
|
||
if (text.indexOf('[ME]\n') === 0) {
|
||
isOutgoing = true;
|
||
text = text.substring('[ME]\n'.length);
|
||
} else if (text === '[ME]') {
|
||
isOutgoing = true;
|
||
text = '';
|
||
}
|
||
currentMsgTexts.push(text);
|
||
var mediaHtml = '', textHtml = linkify(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;
|
||
var replyBody = text.substring(replyTag.length).replace(/^\n/, '');
|
||
// Outgoing-reply: server emits [REPLY]:N\n[ME]\nbody, so we
|
||
// peel [ME] off the inner body too. Without this, replies
|
||
// the user sent themselves would render as incoming.
|
||
if (replyBody.indexOf('[ME]\n') === 0) {
|
||
isOutgoing = true;
|
||
replyBody = replyBody.substring('[ME]\n'.length);
|
||
} else if (replyBody === '[ME]') {
|
||
isOutgoing = true;
|
||
replyBody = '';
|
||
}
|
||
// Build the snippet of the message being replied to (skipped
|
||
// when the target isn't in this batch).
|
||
var previewHtml = '';
|
||
if (replyId > 0 && msgByID[replyId]) {
|
||
var rpText = (msgByID[replyId].Text || msgByID[replyId].text || '').replace(/^\[(?:IMAGE|VIDEO|FILE|AUDIO|STICKER|GIF|POLL|CONTACT|LOCATION|REPLY)[^\]]*\][^\n]*\n?/, '');
|
||
if (rpText.length > 120) rpText = rpText.substring(0, 120) + '…';
|
||
previewHtml = '<div class="reply-preview" onclick="scrollToMsg(' + replyId + ')" title="#' + replyId + '">' + esc(rpText) + '</div>';
|
||
}
|
||
// Parse the body — POLL gets the styled card, otherwise hand
|
||
// off to parseTextMedia so a reply that carries an image
|
||
// (or any other [TAG]) renders a downloadable card instead
|
||
// of dropping the media silently.
|
||
var bodyMediaHtml, bodyTextHtml;
|
||
if (replyBody.indexOf('[POLL]') === 0) {
|
||
var rpPollBody = replyBody.substring('[POLL]'.length).replace(/^\n/, '');
|
||
bodyMediaHtml = '<div class="media-tag">[POLL]</div>';
|
||
bodyTextHtml = renderPollCard(rpPollBody);
|
||
} else {
|
||
var rpParsed = parseTextMedia(replyBody, id);
|
||
bodyMediaHtml = rpParsed.mediaHtml;
|
||
bodyTextHtml = rpParsed.textHtml;
|
||
}
|
||
mediaHtml = '<div class="media-tag reply-tag">[REPLY]</div>' + previewHtml + bodyMediaHtml;
|
||
textHtml = bodyTextHtml;
|
||
} 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 {
|
||
var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[CONTACT]', '[LOCATION]'];
|
||
var rest = text;
|
||
var cards = [];
|
||
while (true) {
|
||
var matchedTag = null;
|
||
for (var m = 0; m < mediaTypes.length; m++) {
|
||
if (rest.indexOf(mediaTypes[m]) === 0) { matchedTag = mediaTypes[m]; break; }
|
||
}
|
||
if (!matchedTag) break;
|
||
var p = parseDownloadableMedia(rest, matchedTag);
|
||
// Bare "[TAG]<word>" form (no leading "\n", non-numeric "<word>") is
|
||
// caption text that happens to start with a tag literal — don't
|
||
// treat it as media. The wire forms are either "[TAG]\n..." or
|
||
// "[TAG]<digit>:<dl>:...".
|
||
var afterTag = rest.charAt(matchedTag.length);
|
||
var isLegacyOrStructured = afterTag === '' || afterTag === '\n' || (afterTag >= '0' && afterTag <= '9');
|
||
if (!isLegacyOrStructured) break;
|
||
cards.push({ tag: matchedTag, parsed: p });
|
||
rest = p.caption;
|
||
}
|
||
if (cards.length === 0) { /* no tag matched */ }
|
||
else if (cards.length === 1) {
|
||
var c0 = cards[0];
|
||
if (c0.parsed.downloadable || c0.parsed.size > 0) {
|
||
mediaHtml = renderDownloadableMedia(c0.tag, c0.parsed, id);
|
||
textHtml = c0.parsed.caption
|
||
? linkify(c0.parsed.caption).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">')
|
||
: '';
|
||
} else {
|
||
mediaHtml = '<div class="media-tag">' + c0.tag + '</div>';
|
||
textHtml = linkify(text.substring(c0.tag.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">');
|
||
}
|
||
} else {
|
||
mediaHtml = '<div class="media-album">';
|
||
for (var k = 0; k < cards.length; k++) {
|
||
var ck = cards[k];
|
||
mediaHtml += renderDownloadableMedia(ck.tag, ck.parsed, id + '-' + k);
|
||
}
|
||
mediaHtml += '</div>';
|
||
textHtml = rest
|
||
? linkify(rest).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 hasMediaCard = mediaHtml.indexOf('media-card') !== -1;
|
||
var msgClasses = 'msg'
|
||
+ (isPersian(text) ? ' rtl-msg' : '')
|
||
+ (hasMediaCard ? ' has-media' : '')
|
||
+ (isOutgoing ? ' msg-outgoing' : '');
|
||
var youTag = isOutgoing ? '<div class="media-tag you-tag">[YOU]</div>' : '';
|
||
html += '<div class="' + msgClasses + '" dir="auto">' + youTag + 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>';
|
||
}
|
||
el.innerHTML = html;
|
||
try { mediaTryRestoreVisibleCards(); } catch (e) { }
|
||
currentMaxMsgID = maxMsgID;
|
||
currentMaxTimestamp = maxTimestamp;
|
||
// On first visit, store max timestamp (no separator shown)
|
||
var chName = channelName(selectedChannel);
|
||
if (isFirstVisit && maxTimestamp > 0) {
|
||
setLastSeenTimestamp(chName, maxTimestamp);
|
||
}
|
||
|
||
if (newMsgSepInserted) {
|
||
// The "new messages" separator is showing. Don't commit lastSeen
|
||
// immediately — keep the separator visible across re-renders for a
|
||
// few seconds so the user actually has time to notice it (otherwise
|
||
// the next refresh would dismiss it instantly).
|
||
var prevSepLastSeen = newMsgSepLastSeen[chName];
|
||
var sepIsNew = prevSepLastSeen !== lastSeenTs;
|
||
newMsgSepLastSeen[chName] = lastSeenTs;
|
||
|
||
// Only scroll to the separator the FIRST time we see this particular
|
||
// separator state — re-renders that don't introduce newer messages
|
||
// shouldn't yank the user's scroll position around.
|
||
if (sepIsNew) {
|
||
newMsgScrollDone = true;
|
||
setTimeout(function () {
|
||
var sep = document.getElementById('newMsgSep');
|
||
if (sep) sep.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, 100);
|
||
} else if (wasAtBottom) {
|
||
// Same separator, but the user is parked at the bottom — keep them
|
||
// at the new bottom so they see the freshest message.
|
||
el.scrollTop = el.scrollHeight;
|
||
} else {
|
||
// Same separator, just a re-render (e.g. another refresh tick or
|
||
// additional new messages within the sticky window). innerHTML reset
|
||
// scrollTop to 0; restore the user's previous position so they
|
||
// aren't teleported away from what they were reading.
|
||
el.scrollTop = prevScrollTop;
|
||
}
|
||
|
||
// Defer (or extend) the commit. After the sticky window expires we
|
||
// mark messages as seen so the separator naturally goes away on the
|
||
// next render.
|
||
if (newMsgSepCommitTimer[chName]) clearTimeout(newMsgSepCommitTimer[chName]);
|
||
var commitTs = maxTimestamp;
|
||
newMsgSepCommitTimer[chName] = setTimeout(function () {
|
||
delete newMsgSepCommitTimer[chName];
|
||
delete newMsgSepLastSeen[chName];
|
||
if (commitTs > 0) setLastSeenTimestamp(chName, commitTs);
|
||
}, NEW_MSG_STICKY_MS);
|
||
} else {
|
||
// No new-messages separator. Behave like before: if user is parked at
|
||
// the bottom (or this is the first render), keep them at the bottom.
|
||
if (wasAtBottom && maxTimestamp > 0 && !isFirstVisit) {
|
||
setLastSeenTimestamp(chName, maxTimestamp);
|
||
}
|
||
if (isFirstRender || wasAtBottom) {
|
||
el.scrollTop = el.scrollHeight;
|
||
document.getElementById('scrollDownBtn').classList.remove('visible');
|
||
} else {
|
||
// User had scrolled up and is reading older messages — restore
|
||
// their position so an in-place re-render doesn't yank them to
|
||
// the top (innerHTML resets scrollTop to 0 by default).
|
||
el.scrollTop = prevScrollTop;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== LOG =====
|
||
function debugLog(line) {
|
||
var el = document.getElementById('cfgDebug');
|
||
if (el && el.checked) addLogLine(line);
|
||
}
|
||
function addLogLine(line) {
|
||
var el = document.getElementById('logPanel');
|
||
var div = document.createElement('div');
|
||
var cls = 'inf';
|
||
if (typeof line === 'string') {
|
||
// Handle structured resolver scan events — show progress bar, suppress from log
|
||
if (line.includes('RESOLVER_SCAN ')) { updateResolverScanDisplay(line); return }
|
||
if (line.includes('SERVER_FETCH_WAIT ')) { updateServerFetchDisplay(line); return }
|
||
if (line.includes('Error:') || line.includes('error') || line.includes('Invalid passphrase')) cls = 'err';
|
||
else if (line.includes('Warning:')) cls = 'warn';
|
||
else if (line.includes('OK') || line.includes('success') || line.includes('done')) cls = 'ok';
|
||
else if (line.match(/\d+%/)) { cls = 'prog'; updateProgressDisplay(line); return }
|
||
}
|
||
div.className = 'log-line ' + cls; div.textContent = line;
|
||
el.appendChild(div); el.scrollTop = el.scrollHeight;
|
||
while (el.children.length > 200) el.removeChild(el.firstChild);
|
||
}
|
||
|
||
function updateServerFetchDisplay(line) {
|
||
var panel = document.getElementById('progressPanel');
|
||
var item = document.getElementById('prog-server-fetch');
|
||
// SERVER_FETCH_WAIT start <totalSec>
|
||
var startMatch = line.match(/SERVER_FETCH_WAIT start (\d+)/);
|
||
if (startMatch) {
|
||
var total = parseInt(startMatch[1]);
|
||
if (!item) {
|
||
item = document.createElement('div'); item.id = 'prog-server-fetch'; item.className = 'progress-item';
|
||
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%;transition:width 1s linear"></div></div>';
|
||
panel.insertBefore(item, panel.firstChild);
|
||
}
|
||
item.dataset.total = total;
|
||
item.querySelector('.progress-label').textContent = t('server_fetch_wait') + ' — ' + total + 's';
|
||
item.querySelector('.progress-fill').style.width = '0%';
|
||
item.dataset.lastUpdate = Date.now();
|
||
return;
|
||
}
|
||
if (!item) return;
|
||
// SERVER_FETCH_WAIT tick <remaining>/<total>
|
||
var tickMatch = line.match(/SERVER_FETCH_WAIT tick (\d+)\/(\d+)/);
|
||
if (tickMatch) {
|
||
var remaining = parseInt(tickMatch[1]), total2 = parseInt(tickMatch[2]);
|
||
var pct = Math.round(((total2 - remaining) / total2) * 100);
|
||
item.querySelector('.progress-label').textContent = t('server_fetch_wait') + ' — ' + remaining + 's';
|
||
item.querySelector('.progress-fill').style.width = pct + '%';
|
||
item.dataset.lastUpdate = Date.now();
|
||
return;
|
||
}
|
||
// SERVER_FETCH_WAIT done
|
||
if (line.includes('SERVER_FETCH_WAIT done')) {
|
||
item.querySelector('.progress-label').textContent = t('loading');
|
||
item.querySelector('.progress-fill').style.width = '100%';
|
||
setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 1200);
|
||
}
|
||
}
|
||
|
||
function ensureResolverScanItem() {
|
||
var item = document.getElementById('prog-resolvers');
|
||
if (!item) {
|
||
var panel = document.getElementById('progressPanel');
|
||
// Remove the generic init loading bar when resolver scan takes over
|
||
var initItem = document.getElementById('prog-init'); if (initItem) initItem.remove();
|
||
item = document.createElement('div'); item.id = 'prog-resolvers'; item.className = 'progress-item';
|
||
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
||
panel.insertBefore(item, panel.firstChild);
|
||
}
|
||
return item;
|
||
}
|
||
|
||
function updateResolverScanDisplay(line) {
|
||
var panel = document.getElementById('progressPanel');
|
||
var item = document.getElementById('prog-resolvers');
|
||
// RESOLVER_SCAN start N
|
||
var startMatch = line.match(/RESOLVER_SCAN start (\d+)/);
|
||
if (startMatch) {
|
||
var total = parseInt(startMatch[1]);
|
||
resolverScanDone = 0; resolverScanHealthy = 0; resolverScanTotal = total;
|
||
item = ensureResolverScanItem();
|
||
item.dataset.total = total;
|
||
item.querySelector('.progress-label').textContent = t('scanning_resolvers') + ' 0/' + total;
|
||
item.querySelector('.progress-fill').style.width = '0%';
|
||
item.dataset.lastUpdate = Date.now();
|
||
resolverScanHint = t('scanning_resolvers') + '... <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
||
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
|
||
return;
|
||
}
|
||
// If the item was removed (e.g. SSE reconnect cleared the panel) but scan
|
||
// is still in progress, re-create it so progress updates keep showing.
|
||
if (!item) item = ensureResolverScanItem();
|
||
// RESOLVER_SCAN progress D/T healthy=H
|
||
var progMatch = line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)(?: healthy=(\d+))?/);
|
||
if (progMatch) {
|
||
var done = parseInt(progMatch[1]), tot = parseInt(progMatch[2]), hlthy = progMatch[3] !== undefined ? parseInt(progMatch[3]) : null;
|
||
// Use authoritative values from the structured message.
|
||
resolverScanDone = done; resolverScanTotal = tot;
|
||
if (hlthy !== null) resolverScanHealthy = hlthy;
|
||
var pct = Math.round((done / tot) * 100);
|
||
var label = t('scanning_resolvers') + ' ' + done + '/' + tot + ' \u2713' + resolverScanHealthy;
|
||
item.querySelector('.progress-label').textContent = label;
|
||
item.querySelector('.progress-fill').style.width = pct + '%';
|
||
item.dataset.lastUpdate = Date.now();
|
||
resolverScanHint = t('scanning_resolvers') + ' (' + done + '/' + tot + ', \u2713' + resolverScanHealthy + ')' + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
||
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
|
||
return;
|
||
}
|
||
// RESOLVER_SCAN cancelled
|
||
if (line.includes('RESOLVER_SCAN cancelled')) {
|
||
resolverScanHint = '';
|
||
if (item && item.parentNode) item.parentNode.removeChild(item);
|
||
return;
|
||
}
|
||
// RESOLVER_SCAN done K/T
|
||
var doneMatch = line.match(/RESOLVER_SCAN done (\d+)\/(\d+)/);
|
||
if (doneMatch) {
|
||
var healthy = parseInt(doneMatch[1]), total2 = parseInt(doneMatch[2]);
|
||
resolverScanDone = total2; resolverScanHealthy = healthy; resolverScanTotal = total2;
|
||
item.querySelector('.progress-label').textContent = t('scanning_resolvers') + ': ' + healthy + '/' + total2 + ' active';
|
||
item.querySelector('.progress-fill').style.width = '100%';
|
||
resolverScanHint = '';
|
||
// Remove init loading bar if it's still around
|
||
var initItem = document.getElementById('prog-init'); if (initItem) initItem.remove();
|
||
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = t('no_channels_hint') + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> ' + t('no_channels_hint2');
|
||
setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 2000);
|
||
// Scan is done — load channels in case the SSE 'update' event was dropped.
|
||
setTimeout(function () { loadChannels().then(function () { if (channels.length > 0 && selectedChannel === 0) selectChannel(1) }) }, 3000);
|
||
refreshResolversBadge();
|
||
}
|
||
}
|
||
|
||
var progressSilencedUntil = 0;
|
||
function updateProgressDisplay(line) {
|
||
// Silence briefly across a profile switch — log events from the
|
||
// previous profile's in-flight fetch keep arriving until the old
|
||
// fetcher's context cancellation lands.
|
||
if (Date.now() < progressSilencedUntil) return;
|
||
var match = line.match(/Channel\s+(\d+)/); if (!match) return;
|
||
var channelNum = parseInt(match[1]);
|
||
var fracMatch = line.match(/\((\d+)\/(\d+)\)/);
|
||
var completed = fracMatch ? parseInt(fracMatch[1]) : 0;
|
||
var total = fracMatch ? parseInt(fracMatch[2]) : 0;
|
||
var pct = line.match(/(\d+)%/); var percent = pct ? parseInt(pct[1]) : 0;
|
||
var ch = channels[channelNum - 1];
|
||
var chName = ch && (ch.Name || ch.name) || '';
|
||
var label = (fracMatch ? (completed + '/' + total) : ('Channel ' + channelNum)) + (chName ? ' (' + chName + ')' : '');
|
||
var panel = document.getElementById('progressPanel');
|
||
var item = document.getElementById('prog-ch-' + channelNum);
|
||
var fetchBar = document.getElementById('prog-fetch-ch-' + channelNum); if (fetchBar) fetchBar.remove();
|
||
if (!item) {
|
||
item = document.createElement('div'); item.id = 'prog-ch-' + channelNum; item.className = 'progress-item';
|
||
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="' + esc(t('dismiss') || 'Dismiss') + '">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
||
panel.appendChild(item);
|
||
}
|
||
item.querySelector('.progress-label').textContent = label;
|
||
item.querySelector('.progress-fill').style.width = percent + '%';
|
||
// Mark last-update time so stale items can be cleaned
|
||
item.dataset.lastUpdate = Date.now();
|
||
if (percent >= 100) setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 800);
|
||
}
|
||
|
||
// Periodically remove stale progress items that stopped updating (missed 'done' event)
|
||
setInterval(function () {
|
||
var now = Date.now();
|
||
var items = document.getElementById('progressPanel').querySelectorAll('.progress-item');
|
||
for (var i = 0; i < items.length; i++) {
|
||
var lu = parseInt(items[i].dataset.lastUpdate || '0');
|
||
if (lu > 0 && now - lu > 30000) items[i].remove();
|
||
}
|
||
}, 10000);
|
||
|
||
function toggleLog() {
|
||
logVisible = !logVisible;
|
||
var p = document.getElementById('logPanel'); var ic = document.getElementById('logToggleIcon');
|
||
p.classList.toggle('hidden', !logVisible);
|
||
ic.innerHTML = logVisible ? '▼' : '▶';
|
||
}
|
||
function openLog() {
|
||
if (logVisible) return;
|
||
logVisible = true;
|
||
document.getElementById('logPanel').classList.remove('hidden');
|
||
document.getElementById('logToggleIcon').innerHTML = '▼';
|
||
}
|
||
function jumpToLog() {
|
||
openChat();
|
||
openLog();
|
||
setTimeout(function () { document.getElementById('logPanel').scrollIntoView({ behavior: 'smooth', block: 'end' }) }, 300);
|
||
}
|
||
|
||
// ===== REFRESH =====
|
||
function showInitProgress() {
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
var p = document.getElementById('progressPanel');
|
||
p.innerHTML = '<div class="progress-item" id="prog-init" data-last-update="' + Date.now() + '"><button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">' + t('loading') + '</div><div class="progress-bar"><div class="progress-fill" style="width:30%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div></div>';
|
||
if (mobileQuery.matches) { openChat(); openLog(); }
|
||
}
|
||
function startAutoRefresh() { if (autoRefreshTimer) return; autoRefreshTimer = setInterval(function () { if (selectedChannel > 0) doRefresh(true) }, 600000) }
|
||
function updateNextFetchDisplay() {
|
||
if (nextFetchInterval) clearInterval(nextFetchInterval);
|
||
var el = document.getElementById('nextFetchTimer');
|
||
var info = document.getElementById('nextFetchInfoBtn');
|
||
if (!serverNextFetch) { el.textContent = ''; if (info) info.style.display = 'none'; return }
|
||
if (info) { info.style.display = ''; info.title = t('next_fetch_info') }
|
||
function tick() {
|
||
var now = Math.floor(Date.now() / 1000), d = serverNextFetch - now;
|
||
if (d <= 0) {
|
||
el.textContent = '';
|
||
return
|
||
}
|
||
var m = Math.floor(d / 60), s = d % 60; el.textContent = m + ':' + (s < 10 ? '0' : '') + s
|
||
}
|
||
tick(); nextFetchInterval = setInterval(tick, 1000);
|
||
}
|
||
// Channel number for which the user explicitly clicked Refresh.
|
||
// The "no new messages" toast fires only for that channel; auto-
|
||
// refresh ticks and channel-open refreshes leave it at 0 so the
|
||
// toast stays silent.
|
||
var manualRefreshChannel = 0;
|
||
|
||
async function doRefreshUI() {
|
||
var btn = document.getElementById('refreshBtn');
|
||
btn.style.animation = 'spin .8s linear';
|
||
showToast(t('refreshing'));
|
||
if (selectedChannel > 0) {
|
||
var ch = channels[selectedChannel - 1];
|
||
var name = (ch && (ch.Name || ch.name)) || 'Channel ' + selectedChannel;
|
||
showChannelFetchProgress(selectedChannel, name);
|
||
refreshingChannels[selectedChannel] = true;
|
||
manualRefreshChannel = selectedChannel;
|
||
}
|
||
doRefresh(false);
|
||
setTimeout(function () { btn.style.animation = '' }, 800);
|
||
}
|
||
async function doRefresh(quiet) {
|
||
try {
|
||
var url = '/api/refresh';
|
||
if (selectedChannel > 0) url += '?channel=' + selectedChannel;
|
||
if (quiet) url += (url.includes('?') ? '&' : '?') + 'quiet=1';
|
||
await fetch(url, { method: 'POST' });
|
||
if (!quiet && selectedChannel > 0) setTimeout(function () { loadChannels(); loadMessages(selectedChannel) }, 3000);
|
||
} catch (e) { }
|
||
}
|
||
|
||
// ===== SEND =====
|
||
async function sendMessage() {
|
||
var input = document.getElementById('sendInput'); var text = input.value.trim();
|
||
if (!text || !selectedChannel) return;
|
||
try {
|
||
var r = await fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: selectedChannel, text: text }) });
|
||
if (r.ok) { input.value = ''; addLogLine('Message sent') }
|
||
else addLogLine('Error: ' + (await r.text()));
|
||
} catch (e) { addLogLine('Error: ' + e.message) }
|
||
}
|
||
|
||
// ===== COPY MSG =====
|
||
function copyMsg(idx) {
|
||
var text = currentMsgTexts[idx]; if (text === undefined) return;
|
||
navigator.clipboard.writeText(text).then(function () { showToast(t('msg_copied')) }).catch(function () { });
|
||
}
|
||
|
||
// ===== SCROLL TO BOTTOM =====
|
||
(function () {
|
||
var messagesEl = null;
|
||
function initScrollBtn() {
|
||
messagesEl = document.getElementById('messages');
|
||
if (!messagesEl) return;
|
||
messagesEl.addEventListener('scroll', function () {
|
||
var atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 150;
|
||
document.getElementById('scrollDownBtn').classList.toggle('visible', !atBottom);
|
||
});
|
||
}
|
||
// Init after DOM is ready
|
||
document.addEventListener('DOMContentLoaded', initScrollBtn, { once: true });
|
||
})();
|
||
function scrollToBottom() {
|
||
var el = document.getElementById('messages');
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
// ===== TOAST =====
|
||
var _toastTimer = null;
|
||
function showToast(msg, ms) {
|
||
var el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
if (_toastTimer) clearTimeout(_toastTimer);
|
||
_toastTimer = setTimeout(function () { el.classList.remove('show'); _toastTimer = null; }, ms || 2200);
|
||
}
|
||
|
||
// ===== UTILITIES =====
|
||
function formatIranTitleHtml(s) {
|
||
return esc(s).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '<img src="/static/iran-lion-sun.svg" alt="\u{1F981}\u2600\uFE0F" style="height:1em;vertical-align:middle">');
|
||
}
|
||
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(raw) {
|
||
// Accepts raw (unescaped) text. Handles [label](url) markdown links and
|
||
// plain URLs. Escapes HTML in non-URL segments so & in URLs is preserved.
|
||
var result = '', last = 0, m;
|
||
var re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>"']+)/g;
|
||
while ((m = re.exec(raw)) !== null) {
|
||
result += esc(raw.slice(last, m.index));
|
||
if (m[2]) {
|
||
result += '<a href="' + escAttr(m[2]) + '" target="_blank" rel="noopener" dir="ltr">' + esc(m[1]) + '</a>';
|
||
} else {
|
||
var url = m[3], trail = '';
|
||
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; }
|
||
}
|
||
result += '<a href="' + escAttr(url) + '" target="_blank" rel="noopener" dir="ltr">' + esc(url) + '</a>' + esc(trail);
|
||
}
|
||
last = m.index + m[0].length;
|
||
}
|
||
result += esc(raw.slice(last));
|
||
return result;
|
||
}
|
||
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');
|
||
populateScanProfileSelect();
|
||
pollScannerOnce();
|
||
}
|
||
function closeScanner() {
|
||
document.getElementById('scannerModal').classList.remove('active');
|
||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||
}
|
||
|
||
function populateScanProfileSelect() {
|
||
var sel = document.getElementById('scanProfile');
|
||
sel.innerHTML = '';
|
||
if (profiles && profiles.profiles) {
|
||
for (var i = 0; i < profiles.profiles.length; i++) {
|
||
var p = profiles.profiles[i];
|
||
var opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = p.nickname || p.id;
|
||
if (p.id === activeProfileId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadScannerPresets() {
|
||
if (scannerActivePreset === 'ir') {
|
||
// Toggle off
|
||
scannerActivePreset = '';
|
||
scannerPresetIpCount = 0;
|
||
renderPresetTag();
|
||
updateScanIpCount();
|
||
return;
|
||
}
|
||
try {
|
||
var r = await fetch('/api/scanner/presets');
|
||
if (!r.ok) return;
|
||
var data = await r.json();
|
||
var presets = data.presets || [];
|
||
if (presets.length > 0) {
|
||
scannerActivePreset = presets[0].name;
|
||
scannerPresetIpCount = presets[0].count || 0;
|
||
renderPresetTag();
|
||
updateScanIpCount();
|
||
}
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
function renderPresetTag() {
|
||
var tag = document.getElementById('scanPresetTag');
|
||
if (scannerActivePreset) {
|
||
tag.style.display = '';
|
||
tag.innerHTML = '<img src="/static/iran-lion-sun.svg" alt="IR" style="height:14px;vertical-align:middle;margin-right:4px"> ' + t('scanner_preset_active');
|
||
} else {
|
||
tag.style.display = 'none';
|
||
tag.textContent = '';
|
||
}
|
||
}
|
||
|
||
async function startScan() {
|
||
var targets = document.getElementById('scanTargets').value.trim().split('\n').filter(function (s) { return s.trim() });
|
||
if (!targets.length && !scannerActivePreset) { showToast(t('scanner_targets')); return }
|
||
// Clear stale results from previous scan.
|
||
scanLastResults = [];
|
||
document.getElementById('scanResultsBody').innerHTML = '';
|
||
document.getElementById('scannerApplySection').style.display = 'none';
|
||
var body = {
|
||
targets: targets,
|
||
preset: scannerActivePreset || undefined,
|
||
profileId: document.getElementById('scanProfile').value,
|
||
rateLimit: parseInt(document.getElementById('scanRateLimit').value) || 50,
|
||
timeout: parseInt(document.getElementById('scanTimeout').value) || 10,
|
||
maxIPs: parseInt(document.getElementById('scanMaxIPs').value) || 0,
|
||
expandSubnet: document.getElementById('scanExpand').checked,
|
||
queryMode: document.getElementById('scanQueryMode').value
|
||
};
|
||
try {
|
||
var r = await fetch('/api/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
if (!r.ok) { showToast(await r.text() || 'Failed to start'); return }
|
||
showScanRunning();
|
||
startScanPolling();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
|
||
async function stopScan() {
|
||
try { await fetch('/api/scanner/stop', { method: 'POST' }) } catch (e) {}
|
||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||
setTimeout(pollScannerOnce, 300);
|
||
}
|
||
|
||
async function toggleScanPause() {
|
||
var btn = document.getElementById('scanPauseBtn');
|
||
var isPaused = btn.dataset.paused === '1';
|
||
try {
|
||
await fetch('/api/scanner/' + (isPaused ? 'resume' : 'pause'), { method: 'POST' });
|
||
pollScannerOnce();
|
||
} catch (e) {}
|
||
}
|
||
|
||
function showScanRunning() {
|
||
document.getElementById('scannerConfig').style.display = 'none';
|
||
document.getElementById('scannerProgressSection').style.display = '';
|
||
document.getElementById('scannerResults').style.display = '';
|
||
document.getElementById('scanStartBtn').style.display = 'none';
|
||
document.getElementById('scanStopBtn').style.display = '';
|
||
document.getElementById('scanPauseBtn').style.display = '';
|
||
document.getElementById('scannerApplySection').style.display = 'none';
|
||
document.getElementById('scannerIconBtn').classList.add('scanning');
|
||
}
|
||
|
||
function showScanIdle() {
|
||
document.getElementById('scannerConfig').style.display = '';
|
||
document.getElementById('scannerProgressSection').style.display = 'none';
|
||
document.getElementById('scannerResults').style.display = 'none';
|
||
document.getElementById('scannerApplySection').style.display = 'none';
|
||
document.getElementById('scanStartBtn').style.display = '';
|
||
document.getElementById('scanStartBtn').textContent = t('scanner_start');
|
||
document.getElementById('scanStopBtn').style.display = 'none';
|
||
document.getElementById('scanPauseBtn').style.display = 'none';
|
||
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
||
}
|
||
|
||
function resetScannerUI() {
|
||
showScanIdle();
|
||
document.getElementById('scannerAboutFull').style.display = 'none';
|
||
document.getElementById('scannerAboutShort').querySelector('a').style.display = '';
|
||
}
|
||
|
||
function showScanDone(progress) {
|
||
document.getElementById('scannerConfig').style.display = 'none';
|
||
document.getElementById('scannerProgressSection').style.display = '';
|
||
document.getElementById('scannerResults').style.display = '';
|
||
document.getElementById('scanStartBtn').style.display = 'none';
|
||
document.getElementById('scanStopBtn').style.display = 'none';
|
||
document.getElementById('scanPauseBtn').style.display = 'none';
|
||
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
||
// Always show the apply section (it has the New Scan button).
|
||
document.getElementById('scannerApplySection').style.display = '';
|
||
if (progress && progress.results && progress.results.length > 0) {
|
||
updateScanSelectedCount();
|
||
}
|
||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||
}
|
||
|
||
function getSelectedScanIPs() {
|
||
var cbs = document.querySelectorAll('.scan-select-cb:checked');
|
||
var ips = [];
|
||
for (var i = 0; i < cbs.length; i++) ips.push(cbs[i].dataset.ip);
|
||
return ips;
|
||
}
|
||
|
||
function updateScanSelectedCount() {
|
||
var n = getSelectedScanIPs().length;
|
||
var label = '(' + n + ')';
|
||
var el1 = document.getElementById('scanAppendCount');
|
||
var el3 = document.getElementById('scanCopyCount');
|
||
if (el1) el1.textContent = label;
|
||
if (el3) el3.textContent = label;
|
||
}
|
||
|
||
function toggleScanSelectAll(checked) {
|
||
var cbs = document.querySelectorAll('.scan-select-cb');
|
||
for (var i = 0; i < cbs.length; i++) cbs[i].checked = checked;
|
||
updateScanSelectedCount();
|
||
}
|
||
|
||
function renderScanProgress(p) {
|
||
var pct = p.total > 0 ? Math.round(p.scanned / p.total * 100) : 0;
|
||
document.getElementById('scanProgressFill').style.width = pct + '%';
|
||
document.getElementById('scanProgressText').textContent = p.scanned + ' / ' + p.total;
|
||
document.getElementById('scanFoundText').textContent = p.found || 0;
|
||
|
||
var stateKey = 'scanner_' + p.state;
|
||
document.getElementById('scanStatusLabel').textContent = t(stateKey);
|
||
|
||
var pauseBtn = document.getElementById('scanPauseBtn');
|
||
if (p.state === 'paused') {
|
||
pauseBtn.textContent = t('scanner_resume');
|
||
pauseBtn.dataset.paused = '1';
|
||
} else {
|
||
pauseBtn.textContent = t('scanner_pause');
|
||
pauseBtn.dataset.paused = '0';
|
||
}
|
||
|
||
// Render results table
|
||
var results = p.results || [];
|
||
scanLastResults = results;
|
||
var body = document.getElementById('scanResultsBody');
|
||
body.innerHTML = '';
|
||
for (var i = 0; i < results.length; i++) {
|
||
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="' + 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(\'' + escAttr(r.ip) + '\');showToast(t(\'copied\'))">☰</button></td>';
|
||
body.appendChild(tr);
|
||
}
|
||
|
||
if (p.state === 'done') {
|
||
showScanDone(p);
|
||
} else if (p.state === 'running' || p.state === 'paused') {
|
||
showScanRunning();
|
||
if (p.state === 'paused') {
|
||
document.getElementById('scanPauseBtn').textContent = t('scanner_resume');
|
||
document.getElementById('scanPauseBtn').dataset.paused = '1';
|
||
}
|
||
}
|
||
}
|
||
|
||
function startScanPolling() {
|
||
if (scanPollTimer) clearInterval(scanPollTimer);
|
||
scanPollTimer = setInterval(pollScannerOnce, 1500);
|
||
}
|
||
|
||
async function pollScannerOnce() {
|
||
try {
|
||
var r = await fetch('/api/scanner/progress');
|
||
if (!r.ok) return;
|
||
var p = await r.json();
|
||
if (p.state === 'running' || p.state === 'paused') {
|
||
renderScanProgress(p);
|
||
if (!scanPollTimer) startScanPolling();
|
||
} else if (p.state === 'done') {
|
||
renderScanProgress(p);
|
||
} else {
|
||
// idle
|
||
if (p.results && p.results.length > 0) {
|
||
renderScanProgress(p);
|
||
showScanDone(p);
|
||
} else {
|
||
showScanIdle();
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function applyScanResults(mode) {
|
||
var ips = getSelectedScanIPs();
|
||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||
try {
|
||
var r = await fetch('/api/scanner/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ resolvers: ips, mode: mode, profileId: document.getElementById('scanProfile').value })
|
||
});
|
||
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
||
showToast(t('scanner_applied'));
|
||
await loadProfiles();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
|
||
function copySelectedScanResults() {
|
||
var ips = getSelectedScanIPs();
|
||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
||
}
|
||
|
||
function copyAllScanResults() {
|
||
var ips = scanLastResults.map(function (r) { return r.ip });
|
||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
||
}
|
||
|
||
// ===== MESSAGE SEARCH =====
|
||
var msgSearchMatches = [], msgSearchIdx = -1;
|
||
// Normalize Arabic/Persian: map ي→ی, ك→ک, ة→ه, etc.
|
||
function normalizeArabicPersian(s) {
|
||
return s
|
||
.replace(/\u064A/g, '\u06CC') // Arabic Ya -> Persian Ya
|
||
.replace(/\u0643/g, '\u06A9') // Arabic Kaf -> Persian Kaf
|
||
.replace(/\u0629/g, '\u0647') // Arabic Ta Marbuta -> He
|
||
.replace(/\u0649/g, '\u06CC') // Arabic Alef Maksura -> Persian Ya
|
||
.replace(/\u06C0/g, '\u0647') // He with Hamza above -> He
|
||
.replace(/[\u0623\u0625\u0622]/g, '\u0627') // Alef variants -> plain Alef
|
||
.replace(/\u0624/g, '\u0648') // Waw with Hamza -> plain Waw
|
||
.replace(/\u0626/g, '\u06CC') // Ya with Hamza -> Ya
|
||
.replace(/\u0621/g, '') // standalone Hamza -> remove
|
||
.replace(/[\u064B-\u065F\u0610-\u061A\u0670]/g, '') // strip tashkil/diacritics
|
||
.replace(/[\u200C\u200D\u200E\u200F]/g, '') // strip ZWNJ, ZWJ, directional marks
|
||
}
|
||
function toggleKebabMenu(e) {
|
||
e.stopPropagation();
|
||
document.getElementById('headerKebabMenu').classList.toggle('open');
|
||
}
|
||
function closeKebabMenu() {
|
||
var m = document.getElementById('headerKebabMenu');
|
||
if (m) m.classList.remove('open');
|
||
}
|
||
document.addEventListener('click', closeKebabMenu);
|
||
|
||
function toggleMsgSearch() {
|
||
var bar = document.getElementById('msgSearchBar');
|
||
if (bar.classList.contains('active')) { closeMsgSearch(); return }
|
||
bar.classList.add('active');
|
||
var inp = document.getElementById('msgSearchInput');
|
||
inp.value = '';
|
||
inp.focus();
|
||
msgSearchMatches = []; msgSearchIdx = -1;
|
||
document.getElementById('msgSearchCount').textContent = '';
|
||
}
|
||
function closeMsgSearch() {
|
||
document.getElementById('msgSearchBar').classList.remove('active');
|
||
document.getElementById('msgSearchInput').value = '';
|
||
// Remove highlights
|
||
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
||
el.outerHTML = el.textContent;
|
||
});
|
||
msgSearchMatches = []; msgSearchIdx = -1;
|
||
document.getElementById('msgSearchCount').textContent = '';
|
||
}
|
||
function doMsgSearch() {
|
||
var q = normalizeArabicPersian(document.getElementById('msgSearchInput').value.trim().toLowerCase());
|
||
// Remove old highlights
|
||
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
||
el.outerHTML = el.textContent;
|
||
});
|
||
msgSearchMatches = []; msgSearchIdx = -1;
|
||
if (!q) { document.getElementById('msgSearchCount').textContent = ''; return }
|
||
var msgs = document.querySelectorAll('.msg');
|
||
msgs.forEach(function (msgEl) {
|
||
highlightTextNodes(msgEl, q);
|
||
});
|
||
msgSearchMatches = Array.from(document.querySelectorAll('.msg .search-highlight'));
|
||
if (msgSearchMatches.length > 0) {
|
||
// Start from the last match (bottom of chat, most recent)
|
||
msgSearchIdx = msgSearchMatches.length - 1;
|
||
scrollToSearchMatch();
|
||
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
||
} else {
|
||
document.getElementById('msgSearchCount').textContent = t('search_no_results');
|
||
}
|
||
}
|
||
function highlightTextNodes(el, q) {
|
||
// Skip metadata and buttons
|
||
if (el.classList && (el.classList.contains('msg-meta') || el.classList.contains('media-tag'))) return;
|
||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
||
var nodes = [];
|
||
while (walker.nextNode()) {
|
||
// Skip nodes inside msg-meta
|
||
var p = walker.currentNode.parentNode;
|
||
var skip = false;
|
||
while (p && p !== el) { if (p.classList && (p.classList.contains('msg-meta') || p.classList.contains('media-tag'))) { skip = true; break } p = p.parentNode; }
|
||
if (!skip) nodes.push(walker.currentNode);
|
||
}
|
||
for (var i = nodes.length - 1; i >= 0; i--) {
|
||
var node = nodes[i];
|
||
var src = node.textContent;
|
||
var normalized = normalizeArabicPersian(src.toLowerCase());
|
||
var idx = normalized.indexOf(q);
|
||
if (idx === -1) continue;
|
||
// Map normalized indices back to original string positions
|
||
var frag = document.createDocumentFragment();
|
||
var pos = 0;
|
||
while (idx !== -1) {
|
||
// Find the original char positions that correspond to normalized idx..idx+q.length
|
||
var origStart = mapNormIdx(src, idx);
|
||
var origEnd = mapNormIdx(src, idx + q.length);
|
||
if (origStart > pos) frag.appendChild(document.createTextNode(src.substring(pos, origStart)));
|
||
var span = document.createElement('span');
|
||
span.className = 'search-highlight';
|
||
span.textContent = src.substring(origStart, origEnd);
|
||
frag.appendChild(span);
|
||
pos = origEnd;
|
||
idx = normalized.indexOf(q, idx + q.length);
|
||
}
|
||
if (pos < src.length) frag.appendChild(document.createTextNode(src.substring(pos)));
|
||
node.parentNode.replaceChild(frag, node);
|
||
}
|
||
}
|
||
// Map a position in normalized text to position in original text
|
||
function mapNormIdx(original, normPos) {
|
||
var ni = 0;
|
||
for (var oi = 0; oi <= original.length; oi++) {
|
||
if (ni >= normPos) return oi;
|
||
if (oi < original.length) {
|
||
var ch = original[oi];
|
||
var norm = normalizeArabicPersian(ch.toLowerCase());
|
||
ni += norm.length;
|
||
}
|
||
}
|
||
return original.length;
|
||
}
|
||
function scrollToSearchMatch() {
|
||
if (msgSearchMatches.length === 0) return;
|
||
msgSearchMatches.forEach(function (el) { el.classList.remove('current') });
|
||
var cur = msgSearchMatches[msgSearchIdx];
|
||
if (cur) { cur.classList.add('current'); cur.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
||
}
|
||
function msgSearchNext() {
|
||
if (msgSearchMatches.length === 0) return;
|
||
msgSearchIdx = (msgSearchIdx + 1) % msgSearchMatches.length;
|
||
scrollToSearchMatch();
|
||
}
|
||
function msgSearchPrev() {
|
||
if (msgSearchMatches.length === 0) return;
|
||
msgSearchIdx = (msgSearchIdx - 1 + msgSearchMatches.length) % msgSearchMatches.length;
|
||
scrollToSearchMatch();
|
||
}
|
||
|
||
// ===== EXPORT MESSAGES =====
|
||
function openExportModal() {
|
||
if (!currentMsgTexts.length) { showToast(t('export_no_messages')); return }
|
||
document.getElementById('exportCount').value = Math.min(10, currentMsgTexts.length);
|
||
document.getElementById('exportCount').max = currentMsgTexts.length;
|
||
document.getElementById('exportModal').classList.add('active');
|
||
}
|
||
function closeExportModal() { document.getElementById('exportModal').classList.remove('active') }
|
||
function doExport() {
|
||
var n = parseInt(document.getElementById('exportCount').value) || 10;
|
||
if (n < 1) n = 1;
|
||
if (n > currentMsgTexts.length) n = currentMsgTexts.length;
|
||
var chName = selectedChannel > 0 && channels[selectedChannel - 1] ? (channels[selectedChannel - 1].Name || channels[selectedChannel - 1].name || 'Channel') : 'Channel';
|
||
// Take last N messages (most recent)
|
||
var start = currentMsgTexts.length - n;
|
||
var lines = [];
|
||
lines.push('=== ' + chName + ' ===');
|
||
for (var i = start; i < currentMsgTexts.length; i++) {
|
||
lines.push('');
|
||
lines.push(currentMsgTexts[i]);
|
||
}
|
||
navigator.clipboard.writeText(lines.join('\n')).then(function () {
|
||
showToast(t('export_copied'));
|
||
closeExportModal();
|
||
}).catch(function () { showToast('Copy failed') });
|
||
}
|
||
|
||
// ===== RESOLVER BANK =====
|
||
var currentResolverTab = 'active';
|
||
function updateResolversBadge(count, bankCount) {
|
||
var badge = document.getElementById('resolversBadge');
|
||
if (!badge) return;
|
||
badge.textContent = count + ' / ' + (bankCount !== undefined ? bankCount : count);
|
||
var total = bankCount !== undefined ? bankCount : count;
|
||
if (total > 500) {
|
||
badge.style.color = 'var(--error, #e74c3c)';
|
||
} else {
|
||
badge.style.color = count > 0 ? 'var(--success, #27ae60)' : 'var(--error, #e74c3c)';
|
||
}
|
||
}
|
||
async function refreshResolversBadge() {
|
||
try {
|
||
var r = await fetch('/api/resolvers/bank');
|
||
if (!r.ok) return;
|
||
var data = await r.json();
|
||
var activeCount = 0;
|
||
(data.bank || []).forEach(function (b) { if (b.active) activeCount++ });
|
||
updateResolversBadge(activeCount, data.count || 0);
|
||
} catch (e) { }
|
||
}
|
||
var resolversRefreshTimer = null;
|
||
function _buildScoreboardTable(board, showRemove, removeFromBank) {
|
||
if (!board.length) return '<div style="color:var(--text-dim)">' + t('no_active_resolvers') + '</div>';
|
||
// Row-card layout — left holds resolver address + a stats line
|
||
// (speed · score · ✅ · ❌), right holds the action buttons.
|
||
// Replaces the old 6-column table that overflowed off the right
|
||
// edge on phone widths and hid the × button.
|
||
var h = '<div class="rb-rows">';
|
||
for (var i = 0; i < board.length; i++) {
|
||
var b = board[i];
|
||
var scoreColor = b.score >= 0.5 ? 'var(--success)' : b.score >= 0.15 ? 'var(--text)' : 'var(--error)';
|
||
var dot = (b.active !== undefined && b.active)
|
||
? ' <span style="color:var(--success);font-size:10px">●</span>' : '';
|
||
h += '<div class="rb-row">';
|
||
h += '<div class="rb-row-main">';
|
||
h += '<div class="rb-row-addr">' + esc(b.addr) + dot + '</div>';
|
||
h += '<div class="rb-row-stats">';
|
||
h += '<span>' + (b.avgMs > 0 ? Math.round(b.avgMs) + 'ms' : '-') + '</span>';
|
||
h += '<span style="color:' + scoreColor + ';font-weight:600">' + b.score.toFixed(2) + '</span>';
|
||
h += '<span style="color:var(--success)">✅ ' + b.success + '</span>';
|
||
h += '<span style="color:var(--error)">❌ ' + b.failure + '</span>';
|
||
h += '</div></div>';
|
||
if (showRemove) {
|
||
var fn = removeFromBank ? 'removeResolverFromBank' : 'removeResolver';
|
||
h += '<div class="rb-row-actions">';
|
||
if (removeFromBank) {
|
||
h += '<button class="rb-row-btn rb-row-add" onclick="openBankAddPicker(this,\'' + esc(b.addr) + '\')" '
|
||
+ 'data-i18n-title="add_to_list" title="Add to list" aria-label="Add to list">+</button>';
|
||
}
|
||
h += '<button class="rb-row-btn rb-row-del" onclick="' + fn + '(\'' + esc(b.addr) + '\')" '
|
||
+ 'title="Remove" aria-label="Remove">×</button>';
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
async function _fetchActiveBoard() {
|
||
var el = document.getElementById('resolverPanelActive');
|
||
try {
|
||
var r = await fetch('/api/resolvers/active');
|
||
if (!r.ok) throw new Error(await r.text());
|
||
var data = await r.json();
|
||
var board = data.scoreboard || [];
|
||
el.innerHTML = _buildScoreboardTable(board, true, false);
|
||
// Sync the Bank tab badge so it stays current while the user
|
||
// sits on the Active view.
|
||
try {
|
||
var br = await fetch('/api/resolvers/bank');
|
||
if (br.ok) {
|
||
var bd = await br.json();
|
||
var bc = document.getElementById('resolverBankCount');
|
||
if (bc) bc.textContent = bd.count || 0;
|
||
}
|
||
} catch (e2) { }
|
||
} catch (e) { el.innerHTML = '<div style="color:var(--error)">' + esc(e.message) + '</div>' }
|
||
}
|
||
async function _fetchBankBoard() {
|
||
var el = document.getElementById('resolverBankListEl');
|
||
try {
|
||
var r = await fetch('/api/resolvers/bank');
|
||
if (!r.ok) throw new Error(await r.text());
|
||
var data = await r.json();
|
||
var bank = data.bank || [];
|
||
var countEl = document.getElementById('resolverBankCount');
|
||
if (countEl) {
|
||
countEl.textContent = data.count || 0;
|
||
countEl.style.color = (data.count || 0) > 500 ? 'var(--error)' : '';
|
||
}
|
||
var activeCount = 0;
|
||
bank.forEach(function (b) { if (b.active) activeCount++ });
|
||
updateResolversBadge(activeCount, data.count || 0);
|
||
el.innerHTML = _buildScoreboardTable(bank, true, true);
|
||
previewBankCleanup();
|
||
} catch (e) { el.innerHTML = '<div style="color:var(--error)">' + esc(e.message) + '</div>' }
|
||
}
|
||
function switchResolverTab(tab) {
|
||
currentResolverTab = tab;
|
||
var pa = document.getElementById('resolverPanelActive');
|
||
var pb = document.getElementById('resolverPanelBank');
|
||
if (pa) pa.style.display = tab === 'active' ? '' : 'none';
|
||
if (pb) pb.style.display = tab === 'bank' ? '' : 'none';
|
||
// Tab styling lives on the dynamic strip — re-render so the
|
||
// accent underline tracks the new selection. renderResolverTabs
|
||
// also reads currentResolverTab and decorates the bank tab.
|
||
renderResolverTabs();
|
||
if (tab === 'active') _fetchActiveBoard();
|
||
else _fetchBankBoard();
|
||
}
|
||
async function openResolversModal() {
|
||
document.getElementById('resolversModal').classList.add('active');
|
||
// Load autoScan from active profile
|
||
var autoScanEl = document.getElementById('bankAutoScan');
|
||
if (autoScanEl && profiles && profiles.profiles && activeProfileId) {
|
||
var p = profiles.profiles.find(function (x) { return x.id === activeProfileId });
|
||
if (p) autoScanEl.checked = p.config.autoScan !== false;
|
||
}
|
||
switchResolverTab('active');
|
||
refreshResolversBadge();
|
||
// Populate the list pill strip at the top of the modal.
|
||
loadResolverLists();
|
||
if (resolversRefreshTimer) clearInterval(resolversRefreshTimer);
|
||
resolversRefreshTimer = setInterval(function () {
|
||
if (!document.getElementById('resolversModal').classList.contains('active')) {
|
||
clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; return;
|
||
}
|
||
if (currentResolverTab === 'active') _fetchActiveBoard();
|
||
else _fetchBankBoard();
|
||
}, 3000);
|
||
}
|
||
function closeResolversModal() {
|
||
document.getElementById('resolversModal').classList.remove('active');
|
||
if (resolversRefreshTimer) { clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; }
|
||
}
|
||
function openScannerFromBank() {
|
||
closeResolversModal();
|
||
openScanner();
|
||
}
|
||
async function doRescanFromBank() {
|
||
closeResolversModal();
|
||
showToast(t('rescan_started'));
|
||
document.getElementById('progressPanel').innerHTML = '';
|
||
showInitProgress();
|
||
try { await fetch('/api/rescan', { method: 'POST' }) } catch (e) { }
|
||
setTimeout(function () { loadChannels().then(function () { if (selectedChannel > 0) loadMessages(selectedChannel) }); refreshResolversBadge(); }, 3000);
|
||
}
|
||
async function toggleBankAutoScan() {
|
||
var checked = document.getElementById('bankAutoScan').checked;
|
||
if (!profiles || !profiles.profiles || !activeProfileId) return;
|
||
var p = profiles.profiles.find(function (x) { return x.id === activeProfileId });
|
||
if (!p) return;
|
||
var profile = JSON.parse(JSON.stringify(p));
|
||
profile.config.autoScan = checked ? undefined : false;
|
||
try {
|
||
await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', profile: profile, skipCheck: true }) });
|
||
await loadProfiles();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
async function removeResolver(addr) {
|
||
try {
|
||
await fetch('/api/resolvers/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addr: addr }) });
|
||
_fetchActiveBoard();
|
||
} catch (e) { }
|
||
}
|
||
async function removeResolverFromBank(addr) {
|
||
try {
|
||
await fetch('/api/resolvers/bank', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addrs: [addr] }) });
|
||
_fetchBankBoard();
|
||
} catch (e) { }
|
||
}
|
||
async function resetScoreboard() {
|
||
try {
|
||
await fetch('/api/resolvers/reset-stats', { method: 'POST' });
|
||
if (currentResolverTab === 'active') _fetchActiveBoard();
|
||
else _fetchBankBoard();
|
||
} catch (e) { }
|
||
}
|
||
function copyResolversList() {
|
||
var panelId = currentResolverTab === 'active' ? 'resolverPanelActive' : 'resolverBankListEl';
|
||
// New row layout uses .rb-row-addr divs in place of <td>.
|
||
var addrs = document.querySelectorAll('#' + panelId + ' .rb-row-addr');
|
||
var lines = [];
|
||
addrs.forEach(function (el) {
|
||
// Strip the trailing active-dot span if present.
|
||
var clone = el.cloneNode(true);
|
||
var dot = clone.querySelector('span');
|
||
if (dot) dot.remove();
|
||
var t = clone.textContent.trim();
|
||
if (t) lines.push(t);
|
||
});
|
||
if (!lines.length) { showToast(t('no_active_resolvers')); return }
|
||
navigator.clipboard.writeText(lines.join('\n')).then(function () { showToast(t('copied')) });
|
||
}
|
||
async function previewBankCleanup() {
|
||
var val = parseFloat(document.getElementById('bankCleanupSlider').value) || 0.1;
|
||
document.getElementById('bankCleanupValue').textContent = val.toFixed(2);
|
||
try {
|
||
var r = await fetch('/api/resolvers/bank/cleanup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ minScore: val, dryRun: true }) });
|
||
if (!r.ok) return;
|
||
var data = await r.json();
|
||
document.getElementById('bankCleanupPreview').innerHTML = '<span style="color:var(--error)">' + data.removed + '</span> ' + t('would_be_removed') + ', <span style="color:var(--success)">' + data.remaining + '</span> ' + t('would_remain');
|
||
} catch (e) { }
|
||
}
|
||
async function doBankCleanup() {
|
||
var val = parseFloat(document.getElementById('bankCleanupSlider').value) || 0.1;
|
||
try {
|
||
var r = await fetch('/api/resolvers/bank/cleanup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ minScore: val }) });
|
||
if (!r.ok) { showToast('Cleanup failed'); return }
|
||
var data = await r.json();
|
||
showToast(t('removed') + ': ' + data.removed + ', ' + t('remaining') + ': ' + data.remaining);
|
||
_fetchBankBoard();
|
||
refreshResolversBadge();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
async function addResolversToBank() {
|
||
var text = document.getElementById('bankAddResolvers').value.trim();
|
||
var resolvers = text.split(/[\n,;\s]+/).map(function (s) { return s.trim() }).filter(Boolean);
|
||
if (!resolvers.length) return;
|
||
try {
|
||
var r = await fetch('/api/resolvers/bank', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolvers: resolvers }) });
|
||
if (!r.ok) { showToast('Add failed'); return }
|
||
var data = await r.json();
|
||
showToast(t('added') + ': ' + data.added);
|
||
document.getElementById('bankAddResolvers').value = '';
|
||
_fetchBankBoard();
|
||
refreshResolversBadge();
|
||
} catch (e) { showToast(e.message) }
|
||
}
|
||
|
||
// ===== BACKGROUND IMAGE =====
|
||
function _setBg(url) {
|
||
var ca = document.querySelector('.chat-area');
|
||
ca.style.backgroundImage = url ? 'url("' + url + '")' : '';
|
||
ca.style.backgroundSize = url ? 'cover' : '';
|
||
ca.style.backgroundPosition = url ? 'center' : '';
|
||
ca.style.backgroundRepeat = url ? 'no-repeat' : '';
|
||
document.getElementById('messages').style.background = url ? 'transparent' : '';
|
||
}
|
||
function loadBgImage() {
|
||
// Use cache-busting query to ensure latest image.
|
||
var url = '/api/bg-image?t=' + Date.now();
|
||
fetch(url).then(function (r) {
|
||
if (r.status === 204 || !r.ok) return;
|
||
_setBg('/api/bg-image?t=' + Date.now());
|
||
}).catch(function () { });
|
||
}
|
||
async function applyBgImage() {
|
||
var inp = document.getElementById('bgImageInput');
|
||
if (!inp.files || !inp.files[0]) return;
|
||
var file = inp.files[0];
|
||
if (file.size > 10 * 1024 * 1024) { showToast('File too large (max 10MB)'); return }
|
||
try {
|
||
// Buffer via FileReader so content:// URIs from Google Photos
|
||
// work too — fetch's stream path fails on those in some WebViews.
|
||
var buf = await new Promise(function (resolve, reject) {
|
||
var fr = new FileReader();
|
||
fr.onload = function () { resolve(fr.result); };
|
||
fr.onerror = function () { reject(fr.error || new Error('read failed')); };
|
||
fr.readAsArrayBuffer(file);
|
||
});
|
||
var r = await fetch('/api/bg-image', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||
body: buf
|
||
});
|
||
if (!r.ok) { showToast(await r.text()); return }
|
||
_setBg('/api/bg-image?t=' + Date.now());
|
||
showToast(t('apply'));
|
||
} catch (e) { showToast((e && e.message) || 'failed'); }
|
||
}
|
||
async function clearBgImage() {
|
||
try { await fetch('/api/bg-image', { method: 'DELETE' }) } catch (e) { }
|
||
_setBg('');
|
||
document.getElementById('bgImageInput').value = '';
|
||
showToast(t('clear_bg'));
|
||
}
|
||
|
||
// ===== EVENTS =====
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' && document.activeElement === document.getElementById('sendInput')) { e.preventDefault(); sendMessage() }
|
||
if (e.key === 'Enter' && document.activeElement === document.getElementById('peAddChannelInput')) { e.preventDefault(); addChannelEditor() }
|
||
if (e.key === 'Enter' && document.activeElement === document.getElementById('msgSearchInput')) { e.preventDefault(); msgSearchNext() }
|
||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal(); closeTelemirror() }
|
||
});
|
||
mobileQuery.addEventListener('change', function () {
|
||
var app = document.getElementById('app');
|
||
if (!mobileQuery.matches) {
|
||
app.classList.remove('chat-open');
|
||
} else if (chatIsOpen) {
|
||
app.classList.add('chat-open');
|
||
}
|
||
});
|
||
|
||
// Handle thefeed:// URI hash import
|
||
(function () { var h = location.hash; if (h && h.startsWith('#thefeed://')) { document.getElementById('importUriInput').value = decodeURIComponent(h.substring(1)); openProfiles(); doImportUri() } })();
|
||
|
||
// ===== AUTO-SCROLL DURING TEXT SELECTION =====
|
||
(function () {
|
||
var scrollSpeed = 0, scrollFrame = null, messagesEl = null;
|
||
function startAutoScroll() {
|
||
if (scrollFrame) return;
|
||
function step() {
|
||
if (scrollSpeed === 0 || !messagesEl) { scrollFrame = null; return }
|
||
messagesEl.scrollTop += scrollSpeed;
|
||
scrollFrame = requestAnimationFrame(step);
|
||
}
|
||
scrollFrame = requestAnimationFrame(step);
|
||
}
|
||
function stopAutoScroll() {
|
||
scrollSpeed = 0;
|
||
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null }
|
||
}
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
messagesEl = document.getElementById('messages');
|
||
if (!messagesEl) return;
|
||
var edgeZone = 40;
|
||
function handleMove(clientY) {
|
||
var sel = window.getSelection();
|
||
if (!sel || sel.isCollapsed) return;
|
||
var rect = messagesEl.getBoundingClientRect();
|
||
if (clientY < rect.top + edgeZone) {
|
||
scrollSpeed = -Math.max(2, (edgeZone - (clientY - rect.top)) / 3);
|
||
startAutoScroll();
|
||
} else if (clientY > rect.bottom - edgeZone) {
|
||
scrollSpeed = Math.max(2, (edgeZone - (rect.bottom - clientY)) / 3);
|
||
startAutoScroll();
|
||
} else { stopAutoScroll() }
|
||
}
|
||
messagesEl.addEventListener('touchmove', function (e) { if (e.touches[0]) handleMove(e.touches[0].clientY) });
|
||
messagesEl.addEventListener('touchend', stopAutoScroll);
|
||
messagesEl.addEventListener('touchcancel', stopAutoScroll);
|
||
messagesEl.addEventListener('mousemove', function (e) { if (e.buttons === 1) handleMove(e.clientY) });
|
||
document.addEventListener('mouseup', stopAutoScroll);
|
||
});
|
||
})();
|
||
|
||
// Close modals (bottom sheets) when clicking outside
|
||
(function () {
|
||
var modalMap = {
|
||
settingsModal: function () { closeSettings() },
|
||
profilesModal: function () { closeProfiles() },
|
||
profileEditorModal: function () { closeProfileEditor && closeProfileEditor() },
|
||
shareProfileModal: function () { closeShareModal() },
|
||
exportModal: function () { closeExportModal() },
|
||
resolversModal: function () { closeResolversModal() },
|
||
scannerModal: function () { closeScanner() },
|
||
savedResolversModal: function () { savedResolversSkip && savedResolversSkip() },
|
||
};
|
||
document.addEventListener('click', function (e) {
|
||
var overlay = e.target;
|
||
if (!overlay.classList.contains('modal-overlay') || !overlay.classList.contains('active')) return;
|
||
// Only close if user clicked directly on the overlay backdrop, not the modal content
|
||
if (e.target !== overlay) return;
|
||
var fn = modalMap[overlay.id];
|
||
if (fn) fn();
|
||
});
|
||
})();
|
||
|
||
init();
|
||
</script>
|
||
<!-- BEGIN telemirror -->
|
||
<script src="/static/telemirror.js"></script>
|
||
<!-- END telemirror -->
|
||
</body>
|
||
|
||
</html> |