mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 08:54:36 +03:00
1384 lines
76 KiB
HTML
1384 lines
76 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">
|
|
<title>thefeed</title>
|
|
<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;
|
|
--hover:#1e2c3a;--font-size:14px;
|
|
}
|
|
*{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;overflow:hidden;font-size:var(--font-size)}
|
|
button{cursor:pointer;font-family:inherit}
|
|
input,textarea,select{font-family:inherit}
|
|
|
|
/* ===== LAYOUT ===== */
|
|
.app{display:flex;height:100vh;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: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)}
|
|
|
|
/* ===== 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-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}
|
|
|
|
/* ===== 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}
|
|
|
|
/* ===== MESSAGES ===== */
|
|
.messages{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:4px;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}
|
|
.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}
|
|
|
|
/* ===== 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:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 3px;line-height:1;flex-shrink:0;opacity:.45;transition:opacity .15s}.msg-copy-btn:hover{opacity:1}
|
|
|
|
/* ===== 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;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}
|
|
|
|
/* ===== 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}
|
|
.share-panel{padding:8px 10px;background:var(--bg);border-top:1px solid var(--border)}
|
|
.share-panel-inner{display:flex;gap:6px;align-items:center}
|
|
.share-uri-input{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--accent);font-family:monospace;font-size:11px;direction:ltr;text-align:left;outline:none}
|
|
.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 ===== */
|
|
@media(max-width:768px){
|
|
.app{position:relative;overflow:hidden}
|
|
.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}
|
|
.msg{max-width:90%}
|
|
.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}
|
|
.modal-overlay.active{align-items:flex-end}
|
|
}
|
|
|
|
::-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">📜</button>
|
|
</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>
|
|
<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" id="refreshBtn" onclick="doRefreshUI()" title="Refresh" style="width:40px;height:40px;font-size:20px">↻</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>
|
|
|
|
<!-- ===== 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>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="savedResolversSkip()" data-i18n="saved_resolvers_skip">Skip</button>
|
|
<button class="btn btn-outline" onclick="savedResolversRescan()" data-i18n="saved_resolvers_rescan">Scan Again</button>
|
|
<button class="btn btn-primary" 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>
|
|
<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 style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);font-size:11px;color:var(--text-dim);display:flex;align-items:center;justify-content:space-between">
|
|
<span data-i18n="version">Version</span>
|
|
<span id="appVersionEl" style="font-family:monospace;color:var(--text)">-</span>
|
|
</div>
|
|
<div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between">
|
|
<span style="font-size:12px;color:var(--text-dim)" data-i18n="clear_cache">Clear Cache</span>
|
|
<button class="btn btn-flat" onclick="clearCache()" style="font-size:11px;padding:4px 12px;color:var(--danger,#e74c3c)" data-i18n="clear_cache">Clear Cache</button>
|
|
</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-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>
|
|
|
|
<!-- ===== 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" 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="form-group"><label data-i18n="resolvers">Resolvers</label><textarea id="peResolvers" placeholder="8.8.8.8 1.1.1.1"></textarea></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">Rate Limit (q/s, 0=unlimited)</label><input type="number" id="peRateLimit" value="5" min="0" step="0.1"></div>
|
|
<div class="form-group"><label data-i18n="scatter">Concurrent requests</label><input type="number" id="peScatter" value="2" min="1" max="5" title="How many resolvers to query simultaneously per DNS request"></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>
|
|
|
|
<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:'برنامه در حال دریافت پیامها است. لطفاً چند لحظه صبر کنید...',
|
|
write_message:'پیام بنویسید...',configure_server:'برای شروع یک سرور راهاندازی کنید',
|
|
set_up:'راهاندازی',switching:'در حال تغییر پروفایل...',
|
|
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:'درخواستهای همزمان',
|
|
channel_mgmt_note:'مدیریت کانال نیاز به پشتیبانی سمت سرور دارد. اگر توسط ادمین غیرفعال شده باشد، افزودن/حذف کار نمی\u200cکند.',
|
|
channel_mgmt_inactive:'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
|
|
channel_placeholder:'نام کاربری کانال',
|
|
version:'نسخه',
|
|
edit:'ویرایش',share:'اشتراک\u200cگذاری',delete:'حذف',save:'ذخیره',cancel:'لغو',
|
|
copied:'کپی شد!',copy:'کپی',active:'فعال',
|
|
private:'خصوصی',no_config:'ابتدا پروفایل را ذخیره کنید',
|
|
refreshing:'در حال بروزرسانی...',fetching_channel:'در حال دریافت کانال...',
|
|
msg_copied:'پیام کپی شد!',rescan_started:'بررسی مجدد شروع شد',
|
|
add_manual:'✎ ساخت دستی',rescan:'بررسی مجدد',
|
|
new_messages:'پیام جدید',missed_messages:'{n} پیام از دست رفته یا حذف شده',
|
|
clear_cache:'پاک کردن کش',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:'ساعت پیش',
|
|
},
|
|
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...',
|
|
write_message:'Write a message...',configure_server:'Configure a server to start reading',
|
|
set_up:'Set Up',switching:'Switching profile...',
|
|
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:'Rate Limit (q/s, 0=unlimited)',
|
|
channels:'Channels',add:'Add',remove:'Remove',
|
|
scatter:'Concurrent requests',
|
|
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',
|
|
copied:'URI copied!',copy:'Copy',active:'Active',
|
|
private:'Private',no_config:'Save a profile first',
|
|
refreshing:'Refreshing...',fetching_channel:'Fetching channel...',
|
|
msg_copied:'Message copied!',rescan_started:'Rescan started',
|
|
add_manual:'✎ Create Manually',rescan:'Rescan',
|
|
new_messages:'New messages',missed_messages:'{n} messages missed or deleted',
|
|
clear_cache:'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',
|
|
}
|
|
};
|
|
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';
|
|
// Re-render dynamic content
|
|
if(channels.length>0)renderChannels();
|
|
}
|
|
function setLang(l){lang=l;localStorage.setItem('thefeed_lang',l);applyLang()}
|
|
|
|
// ===== STATE =====
|
|
var selectedChannel=0,channels=[],eventSource=null,autoRefreshTimer=null,telegramLoggedIn=false,logVisible=false;
|
|
var serverNextFetch=0,nextFetchInterval=null,previousMsgIDs={},currentMsgTexts=[];
|
|
var profiles=null,activeProfileId='',editingProfileId=null,resolverScanHint='',resolverScanHealthy=0,resolverScanDone=0,resolverScanTotal=0;
|
|
|
|
// ===== MOBILE NAV =====
|
|
function openChat(){if(window.innerWidth<=768)document.getElementById('app').classList.add('chat-open')}
|
|
function openSidebar(){document.getElementById('app').classList.remove('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(){
|
|
applyLang();
|
|
await loadFontSize();
|
|
connectSSE();
|
|
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;
|
|
updateNextFetchDisplay();
|
|
await loadChannels();
|
|
if(channels&&channels.length>0)await selectChannel(1);
|
|
else{showInitProgress();await doRefresh();openChat()}
|
|
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.version){var vEl=document.getElementById('appVersionEl');if(vEl)vEl.textContent=s.version+(s.commit&&s.commit!=='unknown'?' ('+s.commit.slice(0,7)+')':'');}
|
|
}catch(e){}
|
|
}
|
|
function previewFontSize(v){document.documentElement.style.setProperty('--font-size',v+'px');document.getElementById('fontSizeVal').textContent=v}
|
|
|
|
// ===== SETTINGS =====
|
|
function openSettings(){document.getElementById('settingsModal').classList.add('active')}
|
|
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();
|
|
}
|
|
async function clearCache(){
|
|
try{var r=await fetch('/api/cache/clear',{method:'POST'});var j=await r.json();if(j.ok){alert(t('cache_cleared'))}}catch(e){}
|
|
}
|
|
|
|
// ===== SAVED RESOLVERS PROMPT =====
|
|
function checkAndShowSavedResolversPrompt(status){
|
|
if(sessionStorage.getItem('thefeed_scan_prompt_shown'))return;
|
|
if(!status.lastScan||!status.lastScan.count)return;
|
|
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');
|
|
var msg=t('saved_resolvers_msg').replace('{n}',ls.count).replace('{t}',ageStr);
|
|
document.getElementById('savedResolversMsg').textContent=msg;
|
|
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 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){
|
|
var data;try{data=JSON.parse(e.data)}catch(x){data=e.data}
|
|
var wasEmpty=channels.length===0;
|
|
var snapChannel=selectedChannel;
|
|
await loadChannels();
|
|
if(wasEmpty&&channels.length>0&&selectedChannel===0){
|
|
closeProfiles();
|
|
await selectChannel(1);return
|
|
}
|
|
if(data&&typeof data==='object'&&data.channel){
|
|
if(data.channel===snapChannel)await loadMessages(data.channel)
|
|
}else if(snapChannel>0){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){
|
|
if(!profiles||!profiles.profiles)return'';
|
|
var p=profiles.profiles.find(function(x){return x.id===id});
|
|
if(!p||!p.config.domain)return'';
|
|
return'thefeed://'+encodeURIComponent(p.config.domain)+'/'+encodeURIComponent(p.config.key)+'?r='+encodeURIComponent((p.config.resolvers||[]).join(','));
|
|
}
|
|
|
|
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();
|
|
var shareId='share-'+p.id;
|
|
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">';
|
|
if(isActive)h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();doRescanFromProfiles()" title="'+t('rescan')+'" style="color:var(--success)">🔄</button>';
|
|
h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();toggleSharePanel(\''+p.id+'\')" title="'+t('share')+'">🔗</button>';
|
|
h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\''+p.id+'\')" title="'+t('edit')+'">✎</button>';
|
|
h+='</div></div>';
|
|
// Share panel (hidden by default)
|
|
h+='<div class="share-panel" id="'+shareId+'" style="display:none">';
|
|
h+='<div class="share-panel-inner">';
|
|
h+='<input class="share-uri-input" type="text" readonly id="suri-'+p.id+'" value="">';
|
|
h+='<button class="btn btn-primary btn-sm" onclick="copyShareUri(\''+p.id+'\')">'+t('copy')+'</button>';
|
|
h+='<button class="btn btn-flat btn-sm" onclick="toggleSharePanel(\''+p.id+'\')">✕</button>';
|
|
h+='</div></div>';
|
|
h+='</div>';
|
|
}
|
|
el.innerHTML=h;
|
|
}
|
|
|
|
function toggleSharePanel(id){
|
|
// Close all first
|
|
document.querySelectorAll('.share-panel').forEach(function(sp){sp.style.display='none'});
|
|
var panel=document.getElementById('share-'+id);
|
|
if(!panel)return;
|
|
var uri=buildProfileUri(id);
|
|
var input=document.getElementById('suri-'+id);
|
|
if(input)input.value=uri||t('no_config');
|
|
panel.style.display='block';
|
|
}
|
|
|
|
function copyShareUri(id){
|
|
var uri=buildProfileUri(id);
|
|
if(!uri){showToast(t('no_config'));return}
|
|
navigator.clipboard.writeText(uri).then(function(){showToast(t('copied'))}).catch(function(){
|
|
var input=document.getElementById('suri-'+id);if(input){input.select();input.setSelectionRange(0,9999);}
|
|
showToast(t('copied'));
|
|
});
|
|
}
|
|
|
|
async function activateProfile(id){
|
|
if(id===activeProfileId){closeProfiles();return}
|
|
try{
|
|
var r=await fetch('/api/profiles/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id})});
|
|
if(!r.ok)return;
|
|
activeProfileId=id;selectedChannel=0;channels=[]; document.getElementById('progressPanel').innerHTML=''; document.getElementById('messages').innerHTML='<div class="empty-state"><p>'+t('switching')+'</p></div>';
|
|
await loadProfiles();closeProfiles();
|
|
await loadChannels();
|
|
if(channels.length>0)await selectChannel(1);
|
|
else{showInitProgress();await doRefresh()}
|
|
}catch(e){}
|
|
}
|
|
|
|
// ===== IMPORT URI =====
|
|
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=[];
|
|
params.split('&').forEach(function(kv){var p=kv.split('=');if(p[0]==='r'&&p[1])resolvers=decodeURIComponent(p[1]).split(',').filter(Boolean)});
|
|
if(!domain||!key){errEl.textContent=t('uri_missing');errEl.style.display='block';return}
|
|
if(!resolvers.length)resolvers=['8.8.8.8','1.1.1.1'];
|
|
var profile={id:'',nickname:domain,config:{domain:domain,key:key,resolvers:resolvers,queryMode:'single',rateLimit:5}};
|
|
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();
|
|
}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('peResolvers').value=(p.config.resolvers||[]).join('\n');
|
|
document.getElementById('peQueryMode').value=p.config.queryMode||'single';
|
|
document.getElementById('peRateLimit').value=p.config.rateLimit||5;
|
|
document.getElementById('peScatter').value=p.config.scatter||2;
|
|
}
|
|
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('peResolvers').value='';
|
|
document.getElementById('peQueryMode').value='single';
|
|
document.getElementById('peRateLimit').value='5';
|
|
document.getElementById('peScatter').value='2';
|
|
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="'+esc(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';
|
|
var nick=document.getElementById('peNick').value.trim();
|
|
var domain=document.getElementById('peDomain').value.trim();
|
|
var key=document.getElementById('peKey').value;
|
|
var resolvers=document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function(s){return s.trim()}).filter(Boolean);
|
|
if(!domain||!key||!resolvers.length){errEl.textContent=t('resolvers')+' / '+t('domain')+' / '+t('passphrase');errEl.style.display='block';return}
|
|
var profile={id:editingProfileId||'',nickname:nick||domain,config:{domain:domain,key:key,resolvers:resolvers,queryMode:document.getElementById('peQueryMode').value,rateLimit:parseFloat(document.getElementById('peRateLimit').value)||5,scatter:parseInt(document.getElementById('peScatter').value)||2}};
|
|
var action=editingProfileId?'update':'create';
|
|
var wasFirst=!profiles||!profiles.profiles||profiles.profiles.length===0;
|
|
try{
|
|
var r=await fetch('/api/profiles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:action,profile:profile})});
|
|
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'}
|
|
}
|
|
|
|
// ===== RESCAN =====
|
|
async function doRescanFromProfiles(){
|
|
closeProfiles();
|
|
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)})},3000);
|
|
}
|
|
|
|
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){}
|
|
}
|
|
|
|
// ===== CACHE (1 h localStorage per profile) =====
|
|
function cacheKey(){return 'thefeed_cache_'+activeProfileId}
|
|
function saveCache(data){try{localStorage.setItem(cacheKey(),JSON.stringify(data))}catch(e){}}
|
|
function loadCache(){
|
|
try{
|
|
var raw=localStorage.getItem(cacheKey());if(!raw)return null;
|
|
var c=JSON.parse(raw);if(Date.now()-c.ts>3600000)return null; // 1 h TTL
|
|
return c;
|
|
}catch(e){return null}
|
|
}
|
|
|
|
// ===== CHANNELS =====
|
|
async function loadChannels(){
|
|
var _c=loadCache();if(_c&&_c.channels&&_c.channels.length){channels=_c.channels;renderChannels();}
|
|
try{
|
|
var r=await fetch('/api/channels');channels=await r.json();if(!channels)channels=[];
|
|
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()}
|
|
var _cache=loadCache()||{messages:{}};_cache.channels=channels;_cache.ts=Date.now();saveCache(_cache);
|
|
}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);
|
|
if(existingItems[ci].dataset.name!==nm){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 lastID=channels[ui].LastMsgID||channels[ui].lastMsgID||0;
|
|
var showBadge=previousMsgIDs[num]>0&&lastID>previousMsgIDs[num]&&num!==selectedChannel;
|
|
var previewEl=existingItems[ui].querySelector('.ch-preview');
|
|
if(previewEl)previewEl.innerHTML=showBadge?'<span class="ch-badge">NEW</span>':'';
|
|
}
|
|
_updateRefreshBadge();return;
|
|
}
|
|
}
|
|
var pubs=[],privs=[];
|
|
for(var i=0;i<channels.length;i++){var c=channels[i];(c.ChatType===1||c.chatType===1?privs: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 name=e.ch.Name||e.ch.name||'Channel '+num2;
|
|
var isPriv=e.ch.ChatType===1||e.ch.chatType===1;
|
|
var active=num2===selectedChannel?' active':'';
|
|
var lastID=e.ch.LastMsgID||e.ch.lastMsgID||0;
|
|
var badge=(previousMsgIDs[num2]>0&&lastID>previousMsgIDs[num2]&&num2!==selectedChannel)?'<span class="ch-badge">NEW</span>':'';
|
|
h+='<div class="ch-item'+active+'" data-name="'+esc(name)+'" onclick="selectChannel('+num2+')">';
|
|
h+='<div class="ch-avatar">'+esc(name.charAt(0).toUpperCase())+'</div>';
|
|
h+='<div class="ch-info"><div class="ch-name">'+esc(name)+(isPriv?'<span class="ch-type-tag">'+t('private')+'</span>':'')+'</div>';
|
|
h+='<div class="ch-preview">'+badge+'</div></div></div>';
|
|
}
|
|
return h;
|
|
}
|
|
el.innerHTML=section('',pubs)+section(t('private'),privs);
|
|
_updateRefreshBadge();
|
|
}
|
|
function _updateRefreshBadge(){
|
|
var hasNew=false;
|
|
for(var k=0;k<channels.length;k++){
|
|
var num3=k+1,lid=channels[k].LastMsgID||channels[k].lastMsgID||0;
|
|
if(previousMsgIDs[num3]>0&&lid>previousMsgIDs[num3]&&num3!==selectedChannel){hasNew=true;break}
|
|
}
|
|
document.getElementById('refreshBtn').classList.toggle('refresh-has-new',hasNew);
|
|
}
|
|
|
|
async function selectChannel(num){
|
|
selectedChannel=num;
|
|
openChat();
|
|
var ch=channels[num-1];var name=(ch&&(ch.Name||ch.name))||'Channel '+num;
|
|
document.getElementById('chatName').textContent=name;
|
|
renderChannels();updateSendPanel();
|
|
document.getElementById('messages').innerHTML='<div class="empty-state"><p>'+t('loading')+'</p></div>';
|
|
document.getElementById('scrollDownBtn').classList.remove('visible');
|
|
// Show immediate feedback progress bar
|
|
showChannelFetchProgress(num,name);
|
|
await loadMessages(num);
|
|
await doRefresh(true);
|
|
}
|
|
|
|
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="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);
|
|
// Auto-remove after messages load or after timeout
|
|
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');
|
|
}
|
|
|
|
// ===== PERSISTENT MESSAGE STORE =====
|
|
function msgStoreKey(chNum){return 'thefeed_msgs_'+activeProfileId+'_'+chNum}
|
|
function loadStoredMessages(chNum){
|
|
try{var raw=localStorage.getItem(msgStoreKey(chNum));return raw?JSON.parse(raw):[]}catch(e){return[]}
|
|
}
|
|
function saveStoredMessages(chNum,msgs){
|
|
try{
|
|
// Keep max 200 messages, sorted by timestamp
|
|
msgs.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)});
|
|
if(msgs.length>200)msgs=msgs.slice(msgs.length-200);
|
|
localStorage.setItem(msgStoreKey(chNum),JSON.stringify(msgs));
|
|
}catch(e){}
|
|
}
|
|
function mergeMessages(stored,fresh){
|
|
var byId={};var merged=[];
|
|
for(var i=0;i<stored.length;i++){var id=stored[i].ID||stored[i].id;if(id){byId[id]=true;merged.push(stored[i])}}
|
|
for(var j=0;j<fresh.length;j++){var id2=fresh[j].ID||fresh[j].id;if(id2&&!byId[id2]){merged.push(fresh[j])}}
|
|
merged.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)});
|
|
if(merged.length>200)merged=merged.slice(merged.length-200);
|
|
return merged;
|
|
}
|
|
|
|
// ===== MESSAGES =====
|
|
async function loadMessages(chNum){
|
|
if(chNum===selectedChannel){
|
|
// Show persisted messages immediately
|
|
var stored=loadStoredMessages(chNum);
|
|
if(stored.length)renderMessages(stored);
|
|
else{var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum])}
|
|
}
|
|
try{
|
|
var r=await fetch('/api/messages/'+chNum);if(chNum!==selectedChannel)return;
|
|
var msgs=await r.json();if(chNum!==selectedChannel)return;
|
|
// Merge with stored messages
|
|
var stored2=loadStoredMessages(chNum);
|
|
var merged=mergeMessages(stored2,msgs||[]);
|
|
saveStoredMessages(chNum,merged);
|
|
renderMessages(merged);
|
|
// Remove fetch progress bar for this channel
|
|
var fetchBar=document.getElementById('prog-fetch-ch-'+chNum);if(fetchBar)fetchBar.remove();
|
|
var _cache=loadCache()||{channels:channels,messages:{}};if(!_cache.messages)_cache.messages={};_cache.messages[''+chNum]=msgs;_cache.ts=Date.now();saveCache(_cache);
|
|
if(channels[chNum-1]){previousMsgIDs[chNum]=channels[chNum-1].LastMsgID||channels[chNum-1].lastMsgID||0;renderChannels()}
|
|
}catch(e){}
|
|
}
|
|
|
|
function renderMessages(msgs){
|
|
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 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='',prevId=0;
|
|
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'};
|
|
for(var i=0;i<msgs.length;i++){
|
|
var msg=msgs[i];
|
|
var id=msg.ID||msg.id;
|
|
// Gap detection: only show if IDs are close enough to be meaningful
|
|
// Telegram IDs can have natural gaps; only flag when we have stored history
|
|
// and the gap is between messages we've seen before vs new ones
|
|
if(prevId>0&&id>prevId+1){
|
|
var gap=id-prevId-1;
|
|
// Only show gap separator for small-ish gaps (likely real missed messages)
|
|
// and only when we have more than just a few messages (initial fetch won't trigger)
|
|
if(gap<=500&&msgs.length>=10){
|
|
html+='<div class="msg-gap-sep"><span>'+t('missed_messages').replace('{n}',gap)+'</span></div>';
|
|
}
|
|
}
|
|
prevId=id;
|
|
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||'';
|
|
currentMsgTexts.push(text);
|
|
var mediaHtml='',textHtml=esc(text);
|
|
var mediaTypes=['[IMAGE]','[VIDEO]','[FILE]','[AUDIO]','[STICKER]','[GIF]','[POLL]','[CONTACT]','[LOCATION]'];
|
|
for(var m=0;m<mediaTypes.length;m++){
|
|
if(text.indexOf(mediaTypes[m])===0){
|
|
mediaHtml='<div class="media-tag">'+mediaTypes[m]+'</div>';
|
|
textHtml=esc(text.substring(mediaTypes[m].length).replace(/^\n/,''));break
|
|
}
|
|
}
|
|
html+='<div class="msg'+(isPersian(text)?' rtl-msg':'')+'" dir="auto">'+mediaHtml+textHtml+'<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg('+i+')" title="'+t('copy')+'">📋</button><span>#'+id+'</span><span>'+timeStr+'</span></div></div>';
|
|
}
|
|
el.innerHTML=html;
|
|
if(isFirstRender||wasAtBottom){el.scrollTop=el.scrollHeight;document.getElementById('scrollDownBtn').classList.remove('visible');}
|
|
}
|
|
|
|
// ===== LOG =====
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
function updateProgressDisplay(line){
|
|
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);
|
|
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="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(window.innerWidth<=768){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')}
|
|
var autoRefreshed=false;
|
|
function tick(){var now=Math.floor(Date.now()/1000),d=serverNextFetch-now;
|
|
if(d<=0){el.textContent='';
|
|
if(!autoRefreshed){autoRefreshed=true;setTimeout(function(){doAutoRefreshAfterCountdown()},3000)}
|
|
return}
|
|
var m=Math.floor(d/60),s=d%60;el.textContent=m+':'+(s<10?'0':'')+s}
|
|
tick();nextFetchInterval=setInterval(tick,1000);
|
|
}
|
|
async function doRefreshUI(){
|
|
var btn=document.getElementById('refreshBtn');
|
|
btn.style.animation='spin .8s linear';
|
|
showToast(t('refreshing'));
|
|
await 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){}
|
|
}
|
|
|
|
async function doAutoRefreshAfterCountdown(){
|
|
// Auto-triggered when server fetch countdown reaches 0
|
|
// Clear metadata cache so we get fresh data
|
|
try{localStorage.removeItem(cacheKey())}catch(e){}
|
|
try{
|
|
// Reload channels (this also fetches /api/status and updates the timer)
|
|
await loadChannels();
|
|
// If we have a selected channel, reload its messages too
|
|
if(selectedChannel>0)await loadMessages(selectedChannel);
|
|
// If the timer still didn't show (server may not have refreshed yet), retry after a delay
|
|
if(!serverNextFetch||serverNextFetch<=Math.floor(Date.now()/1000)){
|
|
setTimeout(async function(){
|
|
try{var sr=await fetch('/api/status');var st=await sr.json();
|
|
if(st.nextFetch&&st.nextFetch>Math.floor(Date.now()/1000)){serverNextFetch=st.nextFetch;updateNextFetchDisplay()}
|
|
}catch(e){}
|
|
},15000);
|
|
}
|
|
}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 =====
|
|
function showToast(msg){
|
|
var el=document.getElementById('toast');el.textContent=msg;el.classList.add('show');
|
|
setTimeout(function(){el.classList.remove('show')},2200);
|
|
}
|
|
|
|
// ===== UTILITIES =====
|
|
function esc(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML}
|
|
function isPersian(text){return text&&(text.match(/[\u0600-\u06FF]/g)||[]).length>text.length*0.25}
|
|
|
|
// ===== 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==='Escape'){closeSettings();closeProfiles();closeProfileEditor()}
|
|
});
|
|
window.addEventListener('resize',function(){if(window.innerWidth>768)document.getElementById('app').classList.remove('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()}})();
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html> |