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:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()">✕</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user