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) {}
}

BIN
web/SC-QR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

348
web/about.html Normal file
View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About — Speakers' Corner Online Directory</title>
<meta name="description" content="Why Speakers' Corner Online exists — private, uncensored conversation for UK residents facing the Online Safety Act, digital ID, and growing surveillance of speech.">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card-bg: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--border: #e0e0e5;
--shadow: 0px 20px 30px rgba(0,0,0,0.12);
--warn: #b91c1c;
--warn-bg: #fef2f2;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card-bg: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
--warn: #fca5a5;
--warn-bg: rgba(185,28,28,0.18);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
position: sticky; top: 0; z-index: 10;
}
.header-inner {
max-width: 860px; margin: 0 auto;
display: flex; align-items: center;
justify-content: space-between; gap: 10px;
}
.logo-text {
font-size: 18px; font-weight: 700;
color: var(--accent); letter-spacing: -0.5px;
}
.container {
max-width: 760px; margin: 0 auto;
padding: 48px 20px 80px;
}
h1 {
font-size: clamp(26px, 5vw, 36px);
font-weight: 700; color: var(--accent);
margin-bottom: 8px;
}
.page-lead {
font-size: 16px; color: var(--muted);
line-height: 1.7; margin-bottom: 40px;
max-width: 640px;
}
.page-lead a { color: var(--accent); text-decoration: none; }
/* Stat banner */
.stat-banner {
background: var(--warn-bg);
border: 1.5px solid var(--warn);
border-radius: 14px;
padding: 22px 28px;
margin-bottom: 32px;
display: flex; gap: 18px; align-items: flex-start;
}
.stat-number {
font-size: clamp(36px, 8vw, 52px);
font-weight: 800; color: var(--warn);
line-height: 1; flex-shrink: 0;
}
.stat-body { flex: 1; }
.stat-body strong {
display: block; font-size: 15px;
color: var(--text); margin-bottom: 5px;
}
.stat-body p { font-size: 13px; color: var(--muted); line-height: 1.6; }
.stat-body a { color: var(--warn); text-decoration: none; font-size: 12px; }
.stat-body a:hover { text-decoration: underline; }
/* Section cards */
.section {
background: var(--card-bg); border-radius: 16px;
box-shadow: var(--shadow); padding: 28px 32px;
margin-bottom: 24px;
}
.section-icon { font-size: 26px; margin-bottom: 10px; display: block; }
.section h2 { font-size: 19px; font-weight: 700; margin-bottom: 14px; color: var(--text); }
.section p {
font-size: 14px; line-height: 1.75; color: var(--muted);
margin-bottom: 10px;
}
.section p:last-child { margin-bottom: 0; }
.section a { color: var(--accent); text-decoration: none; }
.section a:hover { text-decoration: underline; }
.section ol, .section ul { padding-left: 20px; margin-top: 8px; }
.section li { font-size: 14px; line-height: 1.7; color: var(--muted); margin-bottom: 6px; }
.section li:last-child { margin-bottom: 0; }
/* Rule rows */
.rule-list { list-style: none; padding: 0; margin-top: 8px; }
.rule-list li {
display: flex; gap: 12px; align-items: flex-start;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.rule-list li:last-child { border-bottom: none; }
.rule-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); flex-shrink: 0; margin-top: 6px;
}
.rule-body strong { display: block; font-size: 14px; color: var(--text); margin-bottom: 2px; }
.rule-body span { font-size: 13px; color: var(--muted); line-height: 1.55; }
/* Callouts */
.callout {
border-left: 4px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 10px 10px 0;
padding: 12px 16px; margin-top: 14px;
font-size: 13px; color: var(--muted); line-height: 1.65;
}
.callout.warn {
border-left-color: var(--warn);
background: var(--warn-bg);
}
footer {
text-align: center; padding: 28px 20px;
font-size: 12px; color: var(--muted);
border-top: 1px solid var(--border);
}
footer a { color: var(--accent); text-decoration: none; }
@media (max-width: 640px) {
.section { padding: 20px; }
.stat-banner { flex-direction: column; gap: 8px; padding: 18px 20px; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<a href="./index.html" style="text-decoration:none;">
<span class="logo-text">Speakers' Corner Online</span>
</a>
<a href="./index.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">← Directory</a>
</div>
</header>
<div class="container">
<h1>Why This Exists</h1>
<p class="page-lead">
The United Kingdom has quietly become one of the most aggressive surveilleurs of online
speech in the democratic world. Speakers' Corner Online was built as a response — a place
where residents can talk freely, privately, and without fear, using infrastructure that
cannot be monitored or compelled to hand over your conversations.
</p>
<!-- Stat banner -->
<div class="stat-banner">
<div class="stat-number">65,000+</div>
<div class="stat-body">
<strong>arrests in the UK for social media posts since 2017</strong>
<p>
British police forces have made over 65,000 arrests for online speech in less than a decade —
averaging more than 20 arrests every single day. Offences range from "grossly offensive"
messages under the Communications Act to alleged "stirring up" of hatred, applied
increasingly broadly and with little consistency.
</p>
<a href="https://archive.ph/bdEqK" target="_blank">Source: archived report ↗</a>
</div>
</div>
<!-- Online Safety Act -->
<div class="section">
<span class="section-icon">&#128274;</span>
<h2>The Online Safety Act</h2>
<p>
The Online Safety Act 2023 granted Ofcom sweeping powers to demand that platforms
scan private messages for illegal content — a power that, by technical necessity,
requires breaking end-to-end encryption. Put simply: if a platform must be able to
read your messages to check them, your messages are no longer private.
</p>
<p>
The Act also imposes broad "duty of care" obligations that incentivise platforms to
over-censor legal speech to avoid regulatory liability. The practical result is that
mainstream platforms increasingly remove or restrict content that is perfectly lawful —
not because it breaks any law, but because it is cheaper than arguing with a regulator.
</p>
<div class="callout warn">
Signal, WhatsApp and others have threatened to leave the UK market rather than comply
with backdoor requirements. SimpleX's architecture makes compliance technically
impossible — there is no central server holding your keys and no company that can be
served a disclosure order for your messages.
</div>
</div>
<!-- Digital ID -->
<div class="section">
<span class="section-icon">&#127820;</span>
<h2>Digital Identity &amp; the Coming Infrastructure</h2>
<p>
The UK government is rolling out a voluntary Digital Identity and Attributes Trust
Framework that creates the infrastructure for verified digital IDs accepted across
government and private services. "Voluntary" has a habit of becoming mandatory once
the infrastructure exists — access to banking, benefits, travel and eventually online
platforms may increasingly depend on presenting a verified digital identity.
</p>
<p>
Combined with age-verification mandates in the Online Safety Act, the direction of
travel is clear: anonymous online participation is being engineered out of existence.
Once every account is tied to a real identity, the chilling effect on speech becomes
total — people will self-censor on anything that could attract official attention.
</p>
<div class="callout">
SimpleX requires no phone number, no email address, and no government ID.
Your identity on this network is a cryptographic key that exists only on your device.
There is nothing to subpoena and no account to suspend.
</div>
</div>
<!-- Why SimpleX -->
<div class="section">
<span class="section-icon">&#128640;</span>
<h2>Why SimpleX</h2>
<p>
Most "private" messaging apps still require a phone number — which ties every account
to a real identity via your mobile carrier. SimpleX was designed from the ground up to
have no user identifiers at all. Each conversation uses a fresh pair of message queues;
even the platform operator cannot link two conversations to the same person.
</p>
<ul>
<li><strong>No phone number or email required</strong> — connect via a QR code or link.</li>
<li><strong>No central user database</strong> — there is nothing to leak, sell, or hand to police.</li>
<li><strong>End-to-end encrypted</strong> — messages are decrypted only on your device.</li>
<li><strong>Open source</strong> — the code can be audited by anyone.</li>
<li><strong>Self-hostable</strong> — you can run your own relay servers and remain fully independent.</li>
</ul>
<p style="margin-top:12px;">
<a href="https://simplex.chat/downloads/" target="_blank">Download the SimpleX app →</a>
</p>
</div>
<!-- Community rules -->
<div class="section">
<span class="section-icon">&#128220;</span>
<h2>Community Rules</h2>
<p>
We defend the right to hold and express unpopular opinions. The narrow set of rules
below are about direct harm — not offence, not controversy, not dissent.
</p>
<ul class="rule-list">
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No illegal content</strong>
<span>Content that is illegal in the UK — CSAM, credible incitement to imminent violence, etc. — will result in immediate removal and referral to authorities.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No targeted harassment campaigns</strong>
<span>Groups whose sole purpose is to coordinate abuse toward a specific individual are not permitted.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No doxing</strong>
<span>Publishing someone's private personal information (home address, workplace, phone number) without consent is not allowed.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>Label adult content</strong>
<span>Communities containing explicit material must say so clearly in their description.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>Honest descriptions</strong>
<span>Your group's name and description must accurately represent what it is. No spam, no bait-and-switch.</span>
</div>
</li>
</ul>
<div class="callout">
Controversial, offensive, politically extreme, or unpopular speech is explicitly
permitted here. The entire point of this platform is that you do not need our approval
to speak — only your own conscience.
</div>
</div>
<!-- Reporting -->
<div class="section">
<span class="section-icon">&#128226;</span>
<h2>Reporting &amp; Joining</h2>
<p>
To report a listed community that breaches the rules above, connect to the directory bot
via the QR code on the <a href="./index.html">main page</a> and send a brief message
with the community name and the nature of the breach. Reports are reviewed manually.
</p>
<p>
To submit your own group or channel, join the directory bot and follow the prompts.
Listing is free.
</p>
</div>
</div>
<footer>
<p>Speakers' Corner Online &mdash; private communities on the <a href="https://simplex.chat" target="_blank">SimpleX Network</a> &mdash; <a href="./index.html">← Back to Directory</a></p>
</footer>
</body>
</html>

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleXXX Directory</title>
<meta name="description" content="Find communities on the SimpleXXX network">
<title>Speakers' Corner Online Directory</title>
<meta name="description" content="Find communities on the Speakers' Corner Online network">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -52,6 +52,7 @@
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
@@ -321,12 +322,72 @@
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Hero banner ── */
.hero {
width: 100%;
max-height: 260px;
overflow: hidden;
border-radius: 16px;
margin-bottom: 28px;
position: relative;
}
.hero img {
width: 100%;
height: 260px;
object-fit: cover;
object-position: center 40%;
display: block;
}
.dir-summary {
text-align: center;
color: var(--muted);
font-size: 15px;
line-height: 1.6;
max-width: 600px;
margin: 0 auto 28px;
}
/* ── QR popout ── */
.qr-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 20px;
border: 1.5px solid var(--accent); background: transparent;
color: var(--accent); font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: background 0.15s;
}
.qr-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.qr-popout {
display: none;
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.55);
align-items: center; justify-content: center;
}
.qr-popout.open { display: flex; }
.qr-box {
background: var(--card-bg); border-radius: 20px;
padding: 28px; max-width: 320px; width: 90%;
text-align: center; box-shadow: 0 24px 60px rgba(0,0,0,0.35);
position: relative;
}
.qr-box img { width: 100%; border-radius: 10px; display: block; }
.qr-box p { margin-top: 14px; font-size: 13px; color: var(--muted); line-height: 1.5; }
.qr-close {
position: absolute; top: 12px; right: 16px;
background: none; border: none; cursor: pointer;
font-size: 20px; color: var(--muted); line-height: 1;
}
.qr-close:hover { color: var(--text); }
@media (max-width: 640px) {
#directory .entry { flex-direction: column; }
#directory .entry a.img-link { margin-right: 0; }
#directory .entry a.img-link img { width: 72px; height: 72px; min-width: 72px; min-height: 72px; border-radius: 16px; }
.search-container { flex-direction: column; align-items: stretch; }
.sort-tabs { justify-content: center; }
.hero img { height: 160px; }
}
</style>
</head>
@@ -334,12 +395,43 @@
<header>
<div class="header-inner">
<span class="logo-text">SimpleXXX Directory</span>
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
target="_blank" style="text-decoration:none;">
<span class="logo-text">Speakers' Corner Online Directory</span>
</a>
<div style="display:flex;align-items:center;gap:10px;">
<a href="./about.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">About</a>
<button class="qr-btn" onclick="document.getElementById('qr-popout').classList.add('open')">
&#9638; Scan QR
</button>
</div>
</div>
</header>
<!-- QR popout overlay -->
<div id="qr-popout" class="qr-popout" onclick="if(event.target===this)this.classList.remove('open')">
<div class="qr-box">
<button class="qr-close" onclick="document.getElementById('qr-popout').classList.remove('open')">&times;</button>
<img src="./SC-QR.png" alt="Speakers' Corner Online Directory QR code">
<p>Scan with the SimpleX app to connect to the directory, or <a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc" target="_blank" style="color:var(--accent);">tap here</a>.</p>
</div>
</div>
<div class="container">
<h1>SimpleXXX Directory</h1>
<h1>
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
target="_blank" style="color:inherit;text-decoration:none;">Speakers' Corner Online Directory</a>
</h1>
<div class="hero">
<img src="./thedigitalartist-flag-4628030_1920.jpg" alt="Speakers' Corner Online Directory">
</div>
<p class="dir-summary">
A community-run directory of groups and channels on the
<a href="https://simplex.chat" target="_blank" style="color:var(--accent);text-decoration:none;">SimpleX Network</a>.
Browse by activity, discover new communities, and join with one tap — no phone number or account required.
</p>
<!-- Groups / Channels tabs -->
<div class="section-tabs">

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB