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

@@ -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: