From 7c712c9ee3094bb07e466f7b57e2fefa83dbcde4 Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 7 Jun 2026 20:23:00 +0100 Subject: [PATCH] Rich chat messages (reactions, replies, files, images); RSS poll countdown; Speakers' Corner directory page updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- manager/main.py | 54 +++- manager/profiles.py | 118 ++++++- manager/templates/chat.html | 285 +++++++++++++++-- manager/templates/list.html | 41 ++- web/SC-QR.png | Bin 0 -> 2795 bytes web/about.html | 348 +++++++++++++++++++++ web/index.html | 100 +++++- web/thedigitalartist-flag-4628030_1920.jpg | Bin 0 -> 1017568 bytes 8 files changed, 912 insertions(+), 34 deletions(-) create mode 100644 web/SC-QR.png create mode 100644 web/about.html create mode 100644 web/thedigitalartist-flag-4628030_1920.jpg diff --git a/manager/main.py b/manager/main.py index acb7678..b367e4d 100644 --- a/manager/main.py +++ b/manager/main.py @@ -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, }) diff --git a/manager/profiles.py b/manager/profiles.py index 84a9b9c..b85ffd2 100644 --- a/manager/profiles.py +++ b/manager/profiles.py @@ -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: diff --git a/manager/templates/chat.html b/manager/templates/chat.html index febce04..90a827f 100644 --- a/manager/templates/chat.html +++ b/manager/templates/chat.html @@ -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 %} + +