Rich chat messages (reactions, replies, files, images); RSS poll countdown; Speakers' Corner directory page updates

- Chat: extract reactions, quoted replies, file/image data in _normalize_item
- Chat: render emoji reaction pills, reply-quote blocks, inline image previews, file blocks with Accept/Download
- Chat: reply UI (hover → set reply → preview bar above compose → send with quotedItemId)
- Chat: emoji picker strip (6 quick-react emojis) on message hover
- Chat: POST /react and POST /file/{id}/receive and GET /file/{id}/download endpoints
- Chat: file decryption via core.chat_read_file (native libsimplex FFI), served with correct MIME type
- List: RSS bot cards show live next-poll countdown (ticks every second via status API poll_next field)
- Directory: rename SimpleXXX → Speakers' Corner Online Directory throughout
- Directory: add hero banner image, About page link, QR popout, title hyperlink
- Directory: new about.html — Online Safety Act, Digital ID, 65k arrests stat, community rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-07 20:23:00 +01:00
parent 432e4a5e83
commit 7c712c9ee3
8 changed files with 912 additions and 34 deletions

View File

@@ -7,8 +7,10 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
import mimetypes
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -316,14 +318,61 @@ async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id:
text = data.get("text", "").strip()
if not text:
raise HTTPException(400, "text required")
reply_to_id = data.get("reply_to_id")
try:
await pm.send_to_chat(profile_id, chat_type, chat_id, text)
await pm.send_to_chat(profile_id, chat_type, chat_id, text, reply_to_id=reply_to_id)
except Exception as e:
log.error("chat send failed (profile=%s %s/%s): %s", profile_id, chat_type, chat_id, e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/react")
async def chat_react(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
data = await request.json()
item_id = data.get("item_id")
emoji = data.get("emoji", "")
add = data.get("add", True)
if not item_id or not emoji:
raise HTTPException(400, "item_id and emoji required")
try:
await pm.send_reaction(profile_id, chat_type, chat_id, item_id, emoji, add)
except Exception as e:
log.error("react failed: %s", e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/file/{file_id}/receive")
async def file_receive(request: Request, profile_id: int, file_id: int):
_require_auth(request)
try:
await pm.accept_file(profile_id, file_id)
except Exception as e:
log.error("file receive failed: %s", e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.get("/api/profiles/{profile_id}/file/{file_id}/download")
async def file_download(request: Request, profile_id: int, file_id: int):
_require_auth(request)
try:
data, filename = await pm.read_file_bytes(profile_id, file_id)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
log.error("file download failed: %s", e)
raise HTTPException(500, str(e))
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return Response(
content=data,
media_type=mime,
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/clear")
async def chat_clear(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
@@ -425,6 +474,7 @@ async def profile_status(request: Request, profile_id: int):
"contacts": len(bot.contacts) if bot else 0,
"groups": len(bot.groups) if bot else 0,
"log": bot.log_lines[-20:] if bot else [],
"poll_next": bot.poll_next if bot else 0,
})

View File

@@ -163,6 +163,7 @@ class RunningBot:
rss_seen: set = field(default_factory=set) # rss: entry ids already posted
poll_next: float = 0.0 # next scheduled poll (epoch seconds)
channel_gid: int | None = None # broadcast channel group id
file_cache: dict[int, dict] = field(default_factory=dict) # file_id → {name, path, crypto_args}
# profile_id → RunningBot
@@ -242,15 +243,41 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
return False
async def send_to_chat(profile_id: int, chat_type: str, chat_id: int, text: str) -> bool:
async def send_to_chat(
profile_id: int, chat_type: str, chat_id: int, text: str,
reply_to_id: int | None = None,
) -> bool:
"""Send a message directly to a chat by its (type, id) ref. Raises on failure."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
await b.chat.api_send_text_message(
{"chatType": chat_type, "chatId": chat_id}, text, in_reply_to=reply_to_id
)
return True
async def send_reaction(
profile_id: int, chat_type: str, chat_id: int,
item_id: int, emoji: str, add: bool = True,
) -> None:
"""Add or remove an emoji reaction on a message."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_chat_item_reaction(
chat_type, chat_id, item_id, add, {"type": "emoji", "emoji": emoji}
)
async def accept_file(profile_id: int, file_id: int) -> None:
"""Accept an incoming file transfer (moves it from rcvInvitation → downloading)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_receive_file(file_id)
async def refresh_lists(profile_id: int) -> None:
"""Re-fetch contacts and groups (with channel classification) for a running profile.
@@ -275,24 +302,75 @@ async def refresh_lists(profile_id: int) -> None:
def _normalize_item(ci: dict) -> dict:
"""Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI."""
"""Flatten a ChatItem into a dict for the UI, including reactions, quote, file."""
meta = ci.get("meta", {})
chat_dir = ci.get("chatDir", {})
dir_type = chat_dir.get("type", "")
outgoing = dir_type.endswith("Snd")
msg_content = ci.get("content", {}).get("msgContent", {})
content_type = msg_content.get("type", "text")
# Prefer meta.itemText; fall back to content.msgContent.text
text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "")
text = meta.get("itemText") or msg_content.get("text", "")
# Inline image/video preview (base64 data URI embedded in the message)
image_preview = msg_content.get("image", "") if content_type in ("image", "video") else ""
# Sender name: group messages carry the member; direct/own use a generic label
sender = ""
if dir_type == "groupRcv":
sender = chat_dir.get("groupMember", {}).get("localDisplayName", "")
# Emoji reactions: [{emoji, count, me}]
reactions = []
for rc in ci.get("reactions", []):
r = rc.get("reaction", {})
if r.get("type") == "emoji":
reactions.append({
"emoji": r["emoji"],
"count": rc.get("totalReacted", 0),
"me": rc.get("userReacted", False),
})
# Quoted/reply item
quote = None
qi = ci.get("quotedItem")
if qi:
q_content = qi.get("content", {})
q_text = q_content.get("text", "")
q_dir = qi.get("chatDir") or {}
q_dir_type = q_dir.get("type", "")
if q_dir_type in ("directSnd", "groupSnd"):
q_sender = "You"
elif q_dir_type == "groupRcv":
q_sender = q_dir.get("groupMember", {}).get("localDisplayName", "")
else:
q_sender = ""
quote = {"id": qi.get("itemId"), "text": q_text, "sender": q_sender}
# File attachment
file_info = None
f = ci.get("file")
if f:
status = f.get("fileStatus", {}).get("type", "")
path = (f.get("fileSource") or {}).get("filePath", "")
file_info = {
"id": f.get("fileId"),
"name": f.get("fileName", ""),
"size": f.get("fileSize", 0),
"status": status,
"path": path,
}
return {
"id": meta.get("itemId"),
"ts": meta.get("itemTs", ""),
"text": text,
"content_type": content_type,
"image_preview": image_preview,
"outgoing": outgoing,
"sender": sender,
"deleted": "itemDeleted" in meta,
"reactions": reactions,
"quote": quote,
"file": file_info,
}
@@ -305,7 +383,37 @@ async def get_chat_history(
raise RuntimeError("Profile is not running")
chat = await b.chat.api_get_chat(chat_type, chat_id, count)
items = chat.get("chatItems", [])
return [_normalize_item(ci) for ci in items]
normalized = []
for ci in items:
norm = _normalize_item(ci)
f = ci.get("file")
if f and f.get("fileId"):
src = f.get("fileSource") or {}
b.file_cache[f["fileId"]] = {
"name": f.get("fileName", "file"),
"path": src.get("filePath", ""),
"crypto_args": src.get("cryptoArgs"),
}
normalized.append(norm)
return normalized
async def read_file_bytes(profile_id: int, file_id: int) -> tuple[bytes, str]:
"""Return (raw_bytes, filename) for a downloaded file, decrypting if needed."""
b = get_running(profile_id)
if not b:
raise RuntimeError("Profile is not running")
entry = b.file_cache.get(file_id)
if not entry or not entry.get("path"):
raise FileNotFoundError(f"File {file_id} not found in cache — load the chat first")
path = entry["path"]
crypto_args = entry.get("crypto_args")
if crypto_args:
from simplex_chat import core as sc_core
data = await sc_core.chat_read_file(path, crypto_args)
else:
data = Path(path).read_bytes()
return data, entry["name"]
async def clear_chat(profile_id: int, chat_type: str, chat_id: int) -> bool:

View File

@@ -17,18 +17,121 @@
.chat-log {
flex: 1; overflow-y: auto; padding: 18px;
display: flex; flex-direction: column; gap: 8px;
display: flex; flex-direction: column; gap: 6px;
}
/* ── Bubble row (holds avatar space + bubble + actions) ── */
.msg-row {
display: flex; align-items: flex-end; gap: 6px;
position: relative;
}
.msg-row.out { flex-direction: row-reverse; }
/* ── Bubble ── */
.bubble {
max-width: 72%; padding: 8px 12px; border-radius: 14px;
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
font-size: 14px; line-height: 1.4; word-wrap: break-word;
position: relative;
}
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 4px; text-align: right; }
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
.bubble.deleted { font-style: italic; opacity: 0.5; }
/* ── Quote block (replied-to message) ── */
.quote-block {
border-left: 3px solid currentColor;
padding: 4px 8px; margin-bottom: 6px; border-radius: 4px;
font-size: 12px; opacity: 0.75;
background: rgba(0,0,0,0.06);
max-height: 60px; overflow: hidden;
}
.quote-block .q-who { font-weight: 700; margin-bottom: 1px; }
.quote-block .q-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Inline image ── */
.msg-image {
max-width: 100%; max-height: 300px; border-radius: 8px;
display: block; margin-bottom: 4px; cursor: pointer;
}
/* ── File attachment ── */
.file-block {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; margin-bottom: 4px;
background: rgba(0,0,0,0.08); border-radius: 8px;
font-size: 12px;
}
.file-block .f-ico { font-size: 18px; flex-shrink: 0; }
.file-block .f-meta { flex: 1; min-width: 0; }
.file-block .f-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-block .f-size { opacity: 0.6; font-size: 11px; }
.file-block .f-action button, .file-block .f-action a {
font-size: 11px; padding: 3px 8px; border-radius: 6px;
border: 1px solid currentColor; background: transparent; cursor: pointer;
color: inherit; text-decoration: none; display: inline-block;
}
.file-block .f-action button:hover, .file-block .f-action a:hover { background: rgba(255,255,255,0.15); }
/* ── Reactions ── */
.reactions {
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px;
}
.rxn {
display: inline-flex; align-items: center; gap: 3px;
font-size: 13px; padding: 1px 6px;
border-radius: 10px; border: 1px solid var(--border);
background: var(--bg); cursor: pointer;
transition: background 0.1s;
}
.rxn.me { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent); }
.rxn:hover { background: var(--border); }
.rxn .rxn-count { font-size: 11px; opacity: 0.8; }
/* ── Hover action buttons ── */
.msg-actions {
display: none; flex-direction: column; gap: 3px;
align-self: center;
}
.msg-row:hover .msg-actions { display: flex; }
.msg-act-btn {
background: var(--card); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 7px; cursor: pointer;
font-size: 12px; color: var(--text); white-space: nowrap;
}
.msg-act-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Emoji picker strip ── */
.emoji-strip {
display: none; position: absolute; z-index: 10;
background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 5px 8px; gap: 4px;
box-shadow: var(--shadow);
}
.msg-row:hover .emoji-strip { display: flex; }
.msg-row.out .emoji-strip { right: 100%; margin-right: 6px; }
.msg-row:not(.out) .emoji-strip { left: 100%; margin-left: 6px; }
.e-btn { font-size: 18px; cursor: pointer; border: none; background: transparent;
padding: 2px; border-radius: 4px; }
.e-btn:hover { background: var(--bg); }
/* ── Reply preview above compose ── */
.reply-preview {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; background: var(--bg); border-top: 1px solid var(--border);
font-size: 12px; gap: 8px;
}
.reply-preview .rp-bar {
width: 3px; align-self: stretch; border-radius: 2px;
background: var(--accent); flex-shrink: 0;
}
.reply-preview .rp-body { flex: 1; min-width: 0; }
.reply-preview .rp-who { font-weight: 700; color: var(--accent); }
.reply-preview .rp-text { opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reply-preview .rp-cancel { cursor: pointer; opacity: 0.5; font-size: 16px; flex-shrink: 0; }
.reply-preview .rp-cancel:hover { opacity: 1; }
.chat-compose {
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
}
@@ -66,6 +169,15 @@
{% endif %}
</div>
<div id="reply-preview" class="reply-preview" style="display:none;">
<div class="rp-bar"></div>
<div class="rp-body">
<div class="rp-who" id="rp-who"></div>
<div class="rp-text" id="rp-text"></div>
</div>
<span class="rp-cancel" onclick="cancelReply()">&#x2715;</span>
</div>
<div class="chat-compose">
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
{% if not running %}disabled{% endif %}
@@ -81,7 +193,16 @@ const CHAT_ID = {{ chat_id }};
const RUNNING = {{ 'true' if running else 'false' }};
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
let lastIds = '';
let replyTo = null; // {id, sender, text}
const QUICK_EMOJIS = ['👍','❤️','😂','😮','😢','🙏'];
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
}
function fmtTs(iso) {
if (!iso) return '';
@@ -90,10 +211,75 @@ function fmtTs(iso) {
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderQuote(q) {
if (!q) return '';
const who = q.sender ? `<div class="q-who">${escapeHtml(q.sender)}</div>` : '';
const txt = q.text ? `<div class="q-text">${escapeHtml(q.text)}</div>` : '<div class="q-text"><em>attachment</em></div>';
return `<div class="quote-block">${who}${txt}</div>`;
}
function renderImage(m) {
if (!m.image_preview) return '';
// image_preview is a base64 data URI from the message content
const escaped = escapeHtml(m.image_preview);
// If file is downloaded, clicking opens the full-res version
const fullUrl = m.file && m.file.status === 'rcvComplete'
? `/api/profiles/${PROFILE_ID}/file/${m.file.id}/download`
: null;
const img = `<img class="msg-image" src="${escaped}" alt="image"
${fullUrl ? `onclick="window.open('${fullUrl}','_blank')" title="Click to open full size"` : ''}>`;
return img;
}
function renderFile(f) {
if (!f) return '';
const isComplete = f.status === 'rcvComplete' || f.status === 'sndComplete' || f.status === 'sndStored';
const ico = '<i class="fa-solid fa-paperclip"></i>';
const name = escapeHtml(f.name || 'file');
const size = fmtSize(f.size || 0);
let action = '';
if (f.status === 'rcvInvitation') {
action = `<div class="f-action"><button onclick="acceptFile(${f.id},this)"><i class="fa-solid fa-download"></i> Accept</button></div>`;
} else if (isComplete) {
const dlUrl = `/api/profiles/${PROFILE_ID}/file/${f.id}/download`;
action = `<div class="f-action"><a href="${dlUrl}" target="_blank"><i class="fa-solid fa-arrow-down"></i> Download</a></div>`;
} else if (f.status && f.status.startsWith('rcvTransfer')) {
action = `<div class="f-action"><span style="opacity:0.6;font-size:11px;"><i class="fa-solid fa-spinner fa-spin"></i> Downloading…</span></div>`;
}
return `<div class="file-block">
<div class="f-ico">${ico}</div>
<div class="f-meta">
<div class="f-name" title="${name}">${name}</div>
<div class="f-size">${size}</div>
</div>
${action}
</div>`;
}
function renderReactions(reactions, itemId) {
if (!reactions || !reactions.length) return '';
const pills = reactions.map(r => {
const me = r.me ? ' me' : '';
return `<span class="rxn${me}" title="${r.me?'You reacted':''}" onclick="toggleReaction(${itemId},'${escapeHtml(r.emoji)}',${r.me})">${escapeHtml(r.emoji)}<span class="rxn-count">${r.count}</span></span>`;
}).join('');
return `<div class="reactions">${pills}</div>`;
}
function renderEmojiStrip(itemId) {
const btns = QUICK_EMOJIS.map(e =>
`<button class="e-btn" title="React ${e}" onclick="sendReaction(${itemId},'${e}',true,event)">${e}</button>`
).join('');
return `<div class="emoji-strip">${btns}</div>`;
}
function render(messages) {
const log = document.getElementById('chat-log');
const sig = messages.map(m => m.id).join(',');
if (sig === lastIds) return; // nothing new
const sig = messages.map(m => m.id + ':' + (m.reactions||[]).map(r=>r.emoji+r.count).join('')).join(',');
if (sig === lastIds) return;
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
lastIds = sig;
@@ -101,17 +287,35 @@ function render(messages) {
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
return;
}
log.innerHTML = messages.map(m => {
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
}).join('');
if (atBottom) log.scrollTop = log.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
log.innerHTML = messages.map(m => {
const out = m.outgoing;
const dir = out ? 'out' : 'in';
const bubbleCls = `bubble ${dir}${m.deleted ? ' deleted' : ''}`;
const who = (!out && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
const txt = m.deleted ? '(deleted)' : (m.text ? escapeHtml(m.text) : '');
const quoteHtml = renderQuote(m.quote);
const imageHtml = renderImage(m);
const fileHtml = (!imageHtml || m.file) ? renderFile(m.file) : '';
const reactHtml = renderReactions(m.reactions, m.id);
const emojiStrip = m.deleted ? '' : renderEmojiStrip(m.id);
const replyBtn = m.deleted ? '' :
`<button class="msg-act-btn" onclick="setReply(${m.id},${JSON.stringify(escapeHtml(m.sender||'You'))},${JSON.stringify(escapeHtml((m.text||'').slice(0,80)))})" title="Reply"><i class="fa-solid fa-reply"></i></button>`;
const actionsHtml = `<div class="msg-actions">${replyBtn}</div>`;
const inner = `${quoteHtml}${imageHtml}${fileHtml}${who}${txt}${reactHtml}<div class="ts">${fmtTs(m.ts)}</div>`;
return `<div class="msg-row ${dir}" data-id="${m.id}">
${emojiStrip}
${out ? actionsHtml : ''}
<div class="${bubbleCls}">${inner}</div>
${!out ? actionsHtml : ''}
</div>`;
}).join('');
if (atBottom) log.scrollTop = log.scrollHeight;
}
async function loadMessages(force) {
@@ -127,28 +331,69 @@ async function loadMessages(force) {
} catch(e) {}
}
function setReply(id, sender, text) {
replyTo = {id, sender, text};
document.getElementById('rp-who').textContent = sender || 'Unknown';
document.getElementById('rp-text').textContent = text || '(attachment)';
document.getElementById('reply-preview').style.display = 'flex';
document.getElementById('msg-input').focus();
}
function cancelReply() {
replyTo = null;
document.getElementById('reply-preview').style.display = 'none';
}
async function sendMsg() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
const body = {text};
if (replyTo) body.reply_to_id = replyTo.id;
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({text}),
body: JSON.stringify(body),
});
cancelReply();
const data = await resp.json();
if (!data.ok) {
input.value = text; // restore on failure
input.value = text;
alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
return;
}
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
setTimeout(() => loadMessages(true), 250);
}
async function sendReaction(itemId, emoji, add, ev) {
if (ev) ev.stopPropagation();
await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/react`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({item_id: itemId, emoji, add}),
});
setTimeout(() => loadMessages(true), 300);
}
async function toggleReaction(itemId, emoji, currently_reacted) {
await sendReaction(itemId, emoji, !currently_reacted, null);
}
async function acceptFile(fileId, btn) {
btn.disabled = true;
btn.textContent = '…';
const resp = await fetch(`/api/profiles/${PROFILE_ID}/file/${fileId}/receive`, {
method: 'POST', headers: {'X-Token': _token()},
});
const data = await resp.json();
if (!data.ok) { btn.disabled = false; btn.textContent = 'Retry'; return; }
setTimeout(() => loadMessages(true), 500);
}
if (RUNNING) {
loadMessages(true);
setInterval(loadMessages, 3000); // live updates via polling
setInterval(loadMessages, 3000);
}
</script>
{% endblock %}

View File

@@ -105,6 +105,12 @@
onclick="this.textContent='Stopping…'">Stop</button>
</div>
</div>
{% if tab == 'rss-bots' %}
<div style="margin-top:8px;font-size:12px;color:var(--muted);">
<i class="fa-solid fa-clock"></i>
<span id="poll-{{ p.id }}">{{ '—' if not p.running else 'Loading…' }}</span>
</div>
{% endif %}
{% if p.address %}
<div onclick="event.stopPropagation()" style="margin-top:10px;">{{ ui.linkbox(p.address, 'p' ~ p.id) }}</div>
{% endif %}
@@ -304,13 +310,42 @@
</dialog>
<script>
const _pollNextMap = {}; // profile_id → poll_next epoch (seconds)
function fmtCountdown(secs) {
if (secs <= 0) return 'polling now…';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
if (h > 0) return `in ${h}h ${m}m`;
if (m > 0) return `in ${m}m ${s}s`;
return `in ${s}s`;
}
function tickPolls() {
const now = Date.now() / 1000;
for (const [id, pollNext] of Object.entries(_pollNextMap)) {
const el = document.getElementById('poll-' + id);
if (!el) continue;
el.textContent = pollNext > 0 ? fmtCountdown(pollNext - now) : '—';
}
}
setInterval(tickPolls, 1000);
function updateStatus(id, event) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
const badge = document.getElementById('status-' + id);
if (!badge) return;
badge.textContent = data.running ? 'running' : 'stopped';
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
if (badge) {
badge.textContent = data.running ? 'running' : 'stopped';
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
}
if (data.poll_next !== undefined) {
_pollNextMap[id] = data.running ? data.poll_next : 0;
const el = document.getElementById('poll-' + id);
if (el) el.textContent = data.running && data.poll_next > 0
? fmtCountdown(data.poll_next - Date.now() / 1000) : '—';
}
} catch(e) {}
}