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;
|
||||
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
BIN
web/SC-QR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
348
web/about.html
Normal file
348
web/about.html
Normal 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">🔒</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">🍌</span>
|
||||
<h2>Digital Identity & 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">🚀</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">📜</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">📢</span>
|
||||
<h2>Reporting & 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 — private communities on the <a href="https://simplex.chat" target="_blank">SimpleX Network</a> — <a href="./index.html">← Back to Directory</a></p>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
100
web/index.html
100
web/index.html
@@ -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')">
|
||||
▦ 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')">×</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">
|
||||
|
||||
BIN
web/thedigitalartist-flag-4628030_1920.jpg
Normal file
BIN
web/thedigitalartist-flag-4628030_1920.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
Reference in New Issue
Block a user