From ecce417f6d4bf737e73d739bf65d65fa0dccd3b9 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 3 Jun 2026 14:48:24 +0100 Subject: [PATCH] Add chat rooms, channels, sidebar nav, themes, and UI polish Backend (profiles.py / main.py): - Fix bot startup crash: create active user before start_chat - Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink) - Add user profile type alongside bots - Channels: create groups with observer links, classify via acceptMemberRole - Chat rooms: get_chat_history + send_to_chat, history/messages/send routes UI: - Chat room view with message bubbles and live polling - Convert top nav to collapsible left sidebar (mobile-friendly off-canvas) - Three-way Users/Bots split; clickable cards; copy-address buttons + links - Add Matrix theme alongside Original Light/Dark - File upload sidebar link; site footer on all pages incl. login - Bot-type descriptions on the Bots page; QR caption on profiles Remove orphaned index.html (superseded by list.html). Ignore downloaded libs/, exploration DBs, and local ai.sh. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 + manager/download_sdk.sh | 7 + manager/main.py | 174 ++++++++++++++-- manager/profiles.py | 198 ++++++++++++++++-- manager/start.sh | 1 + manager/templates/base.html | 246 ++++++++++++++++++----- manager/templates/chat.html | 154 ++++++++++++++ manager/templates/index.html | 111 ----------- manager/templates/list.html | 183 +++++++++++++++++ manager/templates/login.html | 65 ++++-- manager/templates/profile.html | 344 ++++++++++++++++++++++++++------ manager/templates/settings.html | 153 ++++++++++++++ 12 files changed, 1371 insertions(+), 272 deletions(-) create mode 100755 manager/download_sdk.sh create mode 100644 manager/templates/chat.html delete mode 100644 manager/templates/index.html create mode 100644 manager/templates/list.html create mode 100644 manager/templates/settings.html diff --git a/.gitignore b/.gitignore index dec65ff..c32c59d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ __pycache__/ *.pyc .venv/ *.egg-info/ + +# Downloaded libsimplex artifacts (regenerate via manager/download_sdk.sh) +manager/libs/ +# Throwaway exploration databases +manager/data/explore/ +# Local Claude session resume helper (machine-specific) +manager/ai.sh diff --git a/manager/download_sdk.sh b/manager/download_sdk.sh new file mode 100755 index 0000000..05b71f9 --- /dev/null +++ b/manager/download_sdk.sh @@ -0,0 +1,7 @@ +curl -L --progress-bar \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/v6.5.2/simplex-chat-libs-linux-x86_64.zip" \ + -o /tmp/simplex-libs.zip && \ +unzip /tmp/simplex-libs.zip -d /tmp/simplex-libs-extracted && \ +mkdir -p ~/.cache/simplex-chat/v6.5.2/sqlite && \ +mv /tmp/simplex-libs-extracted/libs/* ~/.cache/simplex-chat/v6.5.2/sqlite/ && \ +echo "Done: $(ls ~/.cache/simplex-chat/v6.5.2/sqlite/)" diff --git a/manager/main.py b/manager/main.py index 3b4fd98..125c578 100644 --- a/manager/main.py +++ b/manager/main.py @@ -27,12 +27,10 @@ AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme") @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() - # Auto-restart any previously running bots on startup for profile in db.list_profiles(): - if profile.get("address"): # had an address = was running before + if profile.get("address"): await pm.start_bot(profile, _save_address) yield - # Graceful shutdown for pid in list(pm._running): await pm.stop_bot(pid) @@ -52,7 +50,6 @@ def _check_auth(request: Request) -> bool: def _require_auth(request: Request) -> None: if not _check_auth(request): - from fastapi.responses import RedirectResponse raise HTTPException(status_code=401, detail="Unauthorized") @@ -62,6 +59,13 @@ def _redirect_if_unauth(request: Request): return None +def _enrich(profiles: list[dict]) -> list[dict]: + for p in profiles: + p["running"] = pm.is_running(p["id"]) + p["config"] = json.loads(p.get("config") or "{}") + return profiles + + # ── Auth ────────────────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) @@ -89,18 +93,38 @@ async def logout(): @app.get("/", response_class=HTMLResponse) async def index(request: Request): + return RedirectResponse("/users", status_code=302) + + +@app.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): if redir := _redirect_if_unauth(request): return redir - all_profiles = db.list_profiles() - for p in all_profiles: - p["running"] = pm.is_running(p["id"]) - p["config"] = json.loads(p.get("config") or "{}") - return TEMPLATES.TemplateResponse(request, "index.html", { - "profiles": all_profiles, - "bot_types": pm.BOT_TYPES, + items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES]) + return TEMPLATES.TemplateResponse(request, "list.html", { + "tab": "users", "items": items, "create_types": pm.USER_TYPES, + "nav_active": "users", }) +@app.get("/bots", response_class=HTMLResponse) +async def bots_page(request: Request): + if redir := _redirect_if_unauth(request): + return redir + items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BOT_TYPES]) + return TEMPLATES.TemplateResponse(request, "list.html", { + "tab": "bots", "items": items, "create_types": pm.BOT_TYPES, + "nav_active": "bots", + }) + + +@app.get("/settings", response_class=HTMLResponse) +async def settings_page(request: Request): + if redir := _redirect_if_unauth(request): + return redir + return TEMPLATES.TemplateResponse(request, "settings.html", {"nav_active": "settings"}) + + @app.get("/profile/{profile_id}", response_class=HTMLResponse) async def profile_page(request: Request, profile_id: int): if redir := _redirect_if_unauth(request): @@ -114,16 +138,90 @@ async def profile_page(request: Request, profile_id: int): contacts = bot.contacts if bot else [] groups = bot.groups if bot else [] log_lines = bot.log_lines[-50:] if bot else [] + is_user = profile["bot_type"] in pm.USER_TYPES + # Split groups by their link role: channels (observer) vs regular groups (member). + # The is_channel flag is set during the bot's group refresh (see _classify_group). + channels = [g for g in groups if g.get("is_channel")] + plain_groups = [g for g in groups if not g.get("is_channel")] return TEMPLATES.TemplateResponse(request, "profile.html", { "profile": profile, "contacts": contacts, - "groups": groups, + "groups": plain_groups, + "channels": channels, "log_lines": log_lines, - "bot_types": pm.BOT_TYPES, + "back": "/users" if is_user else "/bots", + "nav_active": "users" if is_user else "bots", }) -# ── API ─────────────────────────────────────────────────────────────────────── +def _find_chat_name(bot, chat_type: str, chat_id: int) -> str: + """Resolve a chat's display name from the running bot's cached lists.""" + if not bot: + return "" + if chat_type == "direct": + for c in bot.contacts: + if c["contactId"] == chat_id: + return c["localDisplayName"] + elif chat_type == "group": + for g in bot.groups: + if pm.group_id(g) == chat_id: + return pm.group_name(g) + return "" + + +@app.get("/profile/{profile_id}/chat/{chat_type}/{chat_id}", response_class=HTMLResponse) +async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: int): + if redir := _redirect_if_unauth(request): + return redir + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(404, "Profile not found") + if chat_type not in ("direct", "group"): + raise HTTPException(400, "chat_type must be 'direct' or 'group'") + bot = pm.get_running(profile_id) + name = _find_chat_name(bot, chat_type, chat_id) or f"#{chat_id}" + # Is this group a channel? (affects send affordance: broadcast vs reply) + is_channel = False + if chat_type == "group" and bot: + for g in bot.groups: + if pm.group_id(g) == chat_id: + is_channel = bool(g.get("is_channel")) + break + is_user = profile["bot_type"] in pm.USER_TYPES + return TEMPLATES.TemplateResponse(request, "chat.html", { + "profile": profile, + "running": pm.is_running(profile_id), + "chat_type": chat_type, + "chat_id": chat_id, + "chat_name": name, + "is_channel": is_channel, + "nav_active": "users" if is_user else "bots", + }) + + +@app.get("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/messages") +async def chat_messages(request: Request, profile_id: int, chat_type: str, chat_id: int): + _require_auth(request) + count = int(request.query_params.get("count", 50)) + try: + messages = await pm.get_chat_history(profile_id, chat_type, chat_id, count) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"messages": messages}) + + +@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/send") +async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id: int): + _require_auth(request) + data = await request.json() + text = data.get("text", "").strip() + if not text: + raise HTTPException(400, "text required") + ok = await pm.send_to_chat(profile_id, chat_type, chat_id, text) + return JSONResponse({"ok": ok}) + + +# ── Profile API ─────────────────────────────────────────────────────────────── @app.post("/api/profiles") async def create_profile(request: Request): @@ -134,8 +232,8 @@ async def create_profile(request: Request): config = data.get("config", {}) if not name: raise HTTPException(400, "name required") - if bot_type not in pm.BOT_TYPES: - raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}") + if bot_type not in pm.ALL_TYPES: + raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}") try: profile = db.create_profile(name, bot_type, config) except Exception as e: @@ -196,6 +294,50 @@ async def send_message(request: Request, profile_id: int): return JSONResponse({"ok": ok}) +# ── Group / Channel API ─────────────────────────────────────────────────────── + +@app.post("/api/profiles/{profile_id}/groups") +async def create_group(request: Request, profile_id: int): + """Create a group (kind='group', 2-way) or channel (kind='channel', broadcast).""" + _require_auth(request) + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(404, "Profile not found") + data = await request.json() + name = data.get("name", "").strip() + kind = data.get("kind", "group") + if not name: + raise HTTPException(400, "name required") + try: + if kind == "channel": + link = await pm.create_channel(profile_id, name) + else: + link = await pm.create_group(profile_id, name) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"ok": True, "link": link}) + + +@app.get("/api/profiles/{profile_id}/groups/{group_id}/members") +async def group_members(request: Request, profile_id: int, group_id: int): + _require_auth(request) + try: + members = await pm.get_group_members(profile_id, group_id) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"members": members}) + + +@app.get("/api/profiles/{profile_id}/groups/{group_id}/link") +async def group_link(request: Request, profile_id: int, group_id: int): + _require_auth(request) + try: + link = await pm.get_group_link(profile_id, group_id) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"link": link}) + + if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/manager/profiles.py b/manager/profiles.py index 8d74462..a1cd981 100644 --- a/manager/profiles.py +++ b/manager/profiles.py @@ -8,7 +8,32 @@ from typing import Any log = logging.getLogger(__name__) -BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] + +# api_list_groups returns BARE GroupInfo dicts (verified against the live API): +# g["groupId"], g["groupProfile"]["displayName"], +# g["groupSummary"]["currentMembers"], g["membership"]["memberRole"] +# There is no "groupInfo" wrapper and no "members" list in this response. +# +# A "channel" is a group whose join link has acceptMemberRole == "observer" +# (joiners are read-only; only the owner broadcasts). A regular group's link +# has role "member" (2-way). This is the only thing that distinguishes them. + + +def group_name(g: dict) -> str: + return g["groupProfile"]["displayName"] + + +def group_id(g: dict) -> int: + return g["groupId"] + + +def group_member_count(g: dict) -> int: + return g.get("groupSummary", {}).get("currentMembers", 0) + + +BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] +USER_TYPES = ["user"] +ALL_TYPES = BOT_TYPES + USER_TYPES @dataclass @@ -84,18 +109,16 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo if not b or not b.chat: return False try: - contacts = await b.chat.api_list_contacts(1) - for c in contacts: + for c in b.contacts: if c["localDisplayName"] == contact_or_group: await b.chat.api_send_text_message( {"chatType": "direct", "chatId": c["contactId"]}, text ) return True - groups = await b.chat.api_list_groups(1) - for g in groups: - if g["groupInfo"]["groupProfile"]["displayName"] == contact_or_group: + for g in b.groups: + if group_name(g) == contact_or_group: await b.chat.api_send_text_message( - {"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text + {"chatType": "group", "chatId": group_id(g)}, text ) return True except Exception as e: @@ -103,6 +126,53 @@ 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: + """Send a message directly to a chat by its (type, id) ref. Returns True on success.""" + b = get_running(profile_id) + if not b or not b.chat: + return False + try: + await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text) + return True + except Exception as e: + log.error("send_to_chat error: %s", e) + return False + + +def _normalize_item(ci: dict) -> dict: + """Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI.""" + meta = ci.get("meta", {}) + chat_dir = ci.get("chatDir", {}) + dir_type = chat_dir.get("type", "") + outgoing = dir_type.endswith("Snd") + # Prefer meta.itemText; fall back to content.msgContent.text + text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "") + # 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", "") + return { + "id": meta.get("itemId"), + "ts": meta.get("itemTs", ""), + "text": text, + "outgoing": outgoing, + "sender": sender, + "deleted": "itemDeleted" in meta, + } + + +async def get_chat_history( + profile_id: int, chat_type: str, chat_id: int, count: int = 50 +) -> list[dict]: + """Return the last `count` messages of a chat, oldest-first, normalized for the UI.""" + b = get_running(profile_id) + if not b or not b.chat: + 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] + + async def _run_bot( profile_id: int, name: str, @@ -123,27 +193,34 @@ async def _run_bot( try: chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix)) b.chat = chat - await chat.start_chat() - # Create or fetch address + # libsimplex /_start requires an active user to exist first user = await chat.api_get_active_user() if not user: user = await chat.api_create_active_user( {"displayName": name, "fullName": ""} ) - user_id = user["userId"] - addr = await chat.api_get_user_address(user_id) - if not addr: - addr = await chat.api_create_user_address(user_id) + await chat.start_chat() - address = addr.get("connShortLink") or addr.get("connFullLink", "") + user_id = user["userId"] + existing = await chat.api_get_user_address(user_id) + if existing: + # api_get_user_address returns UserContactLink; link is nested under connLinkContact + link = existing["connLinkContact"] + else: + # api_create_user_address returns CreatedConnLink directly + link = await chat.api_create_user_address(user_id) + + address = link.get("connShortLink") or link.get("connFullLink", "") b.address = address await on_address(profile_id, address) - # Configure address settings based on bot type + # Configure address settings based on profile type settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}} - if bot_type == "support": + if bot_type == "user": + pass # plain user: auto-accept on, no auto-reply + elif bot_type == "support": settings["businessAddress"] = True welcome = config.get("welcome_message", f"Welcome to {name} support.") settings["autoReply"] = {"type": "text", "text": welcome} @@ -157,9 +234,13 @@ async def _run_bot( async def refresh() -> None: try: b.contacts = await chat.api_list_contacts(user_id) - b.groups = await chat.api_list_groups(user_id) + groups = await chat.api_list_groups(user_id) + # Classify each group as channel (observer link) vs group (member link) + for g in groups: + await _classify_group(chat, g) + b.groups = groups except Exception: - pass + log.exception("refresh failed for bot %d", profile_id) await refresh() @@ -241,3 +322,84 @@ def _append_log(b: RunningBot, line: str) -> None: b.log_lines.append(line) if len(b.log_lines) > 200: b.log_lines = b.log_lines[-200:] + + +async def _classify_group(chat: Any, g: dict) -> None: + """Annotate a GroupInfo in place with link info: is_channel, link. + + A channel is a group whose join-link role is "observer". Groups with a + "member"+ link (or no link at all) are regular 2-way groups. + """ + g["is_channel"] = False + g["link"] = "" + try: + link_obj = await chat.api_get_group_link(g["groupId"]) + except Exception: + return # no link → plain private group + g["is_channel"] = link_obj.get("acceptMemberRole") == "observer" + conn = link_obj.get("connLinkContact", {}) + g["link"] = conn.get("connShortLink") or conn.get("connFullLink", "") + + +async def create_channel(profile_id: int, name: str) -> str: + """Create a group with an observer join link (a SimpleX channel). Returns the link.""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + user = await b.chat.api_get_active_user() + if not user: + raise RuntimeError("No active user for this profile") + info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""}) + link = await b.chat.api_create_group_link(info["groupId"], "observer") + # Refresh cached group list (re-classifies all groups including the new channel) + try: + groups = await b.chat.api_list_groups(user["userId"]) + for g in groups: + await _classify_group(b.chat, g) + b.groups = groups + except Exception: + log.exception("group refresh after create_channel failed") + return link + + +async def create_group(profile_id: int, name: str) -> str: + """Create a regular 2-way group with a member join link. Returns the link.""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + user = await b.chat.api_get_active_user() + if not user: + raise RuntimeError("No active user for this profile") + info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""}) + link = await b.chat.api_create_group_link(info["groupId"], "member") + try: + groups = await b.chat.api_list_groups(user["userId"]) + for g in groups: + await _classify_group(b.chat, g) + b.groups = groups + except Exception: + log.exception("group refresh after create_group failed") + return link + + +async def get_group_members(profile_id: int, gid: int) -> list[dict]: + """Return the member list for a group/channel (excludes the owner themselves).""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + members = await b.chat.api_list_members(gid) + return [ + {"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]} + for m in members + ] + + +async def get_group_link(profile_id: int, gid: int) -> str: + """Return the existing join link for a group/channel (empty if none).""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + try: + return await b.chat.api_get_group_link_str(gid) + except Exception: + return "" diff --git a/manager/start.sh b/manager/start.sh index e6b69d1..13fd740 100755 --- a/manager/start.sh +++ b/manager/start.sh @@ -12,6 +12,7 @@ if [ ! -d ".venv" ]; then fi mkdir -p data/bots +mkdir -p static # Set token — override via: MANAGER_TOKEN=mysecret ./start.sh export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}" diff --git a/manager/templates/base.html b/manager/templates/base.html index 951ba63..ed658da 100644 --- a/manager/templates/base.html +++ b/manager/templates/base.html @@ -1,12 +1,19 @@ + {% block title %}SimpleX Manager{% endblock %} diff --git a/manager/templates/chat.html b/manager/templates/chat.html new file mode 100644 index 0000000..bc7d6b6 --- /dev/null +++ b/manager/templates/chat.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ ← {{ profile.name }} + / + {{ chat_name }} + {{ 'channel' if is_channel else chat_type }} +
+ +
+
+ {{ chat_name }} + +
+ + {% if is_channel %} +
📢 Channel — messages you send here broadcast to all subscribers.
+ {% endif %} + +
+ {% if not running %} +
Profile is stopped. Start it to load messages.
+ {% else %} +
Loading messages…
+ {% endif %} +
+ +
+ + +
+
+ + +{% endblock %} diff --git a/manager/templates/index.html b/manager/templates/index.html deleted file mode 100644 index e3d11f2..0000000 --- a/manager/templates/index.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "base.html" %} -{% block title %}Profiles — SimpleX Manager{% endblock %} - -{% block content %} -
-

Bot Profiles

- -
- -{% if profiles %} -
- {% for p in profiles %} -
-
-
- {{ p.name }} - {{ p.bot_type }} - - {% if p.running %}running{% else %}stopped{% endif %} - -
-
- View - - -
-
- {% if p.address %} -
{{ p.address }}
- {% endif %} -
- {% endfor %} -
-{% else %} -
- No profiles yet. Create one to get started. -
-{% endif %} - - - -

New Bot Profile

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -{% endblock %} diff --git a/manager/templates/list.html b/manager/templates/list.html new file mode 100644 index 0000000..1f1f8a4 --- /dev/null +++ b/manager/templates/list.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} +{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

{{ tab | title }}

+ +
+ +{% if tab == 'bots' %} +
+

Available bot types

+ + + + + + +
echoRepeats every message back to the sender — handy for testing a connection end to end.
broadcastRelays messages from authorized publishers out to all of the bot's contacts.
supportBusiness inbox — auto-replies with a welcome message and collects incoming inquiries.
directoryDirectory service for discovering and listing groups or contacts.
deadmansDead man's switch — triggers an action if expected check-ins stop arriving.
+
+{% endif %} + +{% if items %} + {% for p in items %} +
+
+
+ {{ p.name }} + {{ p.bot_type }} + + {% if p.running %}running{% else %}stopped{% endif %} + +
+
+ + +
+
+ {% if p.address %} +
+ + {{ p.address }} +
+ {% endif %} +
+ {% endfor %} +{% else %} +
+ {% if tab == 'users' %} + No users yet +

Create a SimpleX user account to manage contacts and channels.

+ {% else %} + No bots yet +

Bots can echo messages, broadcast to subscribers, or run automated tasks.

+ {% endif %} +
+{% endif %} + + + +

New {{ 'User' if tab == 'users' else 'Bot' }}

+
+
+ + +
+ {% if tab == 'bots' %} +
+ + +
+
+ + +
+ {% endif %} +
+ + +
+
+
+ + +{% endblock %} diff --git a/manager/templates/login.html b/manager/templates/login.html index 764e118..d9f7bbc 100644 --- a/manager/templates/login.html +++ b/manager/templates/login.html @@ -1,18 +1,39 @@ + SimpleX Manager — Login -
-

SimpleX Manager

- {% if error %}
{{ error }}
{% endif %} -
- - - -
+ +
+ © Bournemouth Technology Ltd + · + built on © SimpleX Network + · + Get SimpleX App +
diff --git a/manager/templates/profile.html b/manager/templates/profile.html index c4eed02..84a9c41 100644 --- a/manager/templates/profile.html +++ b/manager/templates/profile.html @@ -5,17 +5,34 @@ {% endblock %} {% block content %}
- ← Profiles + ← {{ 'Users' if back == '/users' else 'Bots' }} / {{ profile.name }} - {{ profile.bot_type }} + {{ profile.bot_type }} {% if profile.running %}running{% else %}stopped{% endif %} @@ -43,16 +60,21 @@

Address

{% if profile.address %} -
{{ profile.address }}
+
+

Scan QR code from mobile app to start a chat

{% else %} -

Start the bot to generate an address.

+

Start the profile to generate an address.

{% endif %}
@@ -72,37 +94,19 @@
- -
-

Send Message

-
-
- - - - {% for c in contacts %} -
-
- - -
- - -
-
-

Contacts ({{ contacts | length }})

{% if contacts %} - + {% for c in contacts %} - - - + + + {% endfor %}
NameID
Name
{{ c.localDisplayName }}{{ c.contactId }}
{{ c.localDisplayName }} + 💬 Chat +
@@ -111,21 +115,64 @@ {% endif %}
+ {# Macro: one group/channel row. api_list_groups gives bare GroupInfo dicts: + g.groupId, g.groupProfile.displayName, g.groupSummary.currentMembers. + The verb is "Post" for channels (broadcast) and "Msg" for groups. #} + {% macro groupRow(g) %} + {% set name = g.groupProfile.displayName %} + {% set gid = g.groupId %} + {% set mcnt = g.groupSummary.currentMembers %} + + {{ name }} + + + + + + + + {% endmacro %} +
-

Groups ({{ groups | length }})

+
+

Groups ({{ groups | length }})

+ {% if profile.running %} + + {% endif %} +
{% if groups %} - - {% for g in groups %} - - - - - {% endfor %} + + {% for g in groups %}{{ groupRow(g) }}{% endfor %}
NameMembers
{{ g.groupInfo.groupProfile.displayName }}{{ g.members | length }}
NameMembers
{% else %} -

No groups yet.

+

No groups yet.{% if not profile.running %} Start the profile first.{% endif %}

+ {% endif %} +
+ + +
+
+

Channels ({{ channels | length }})

+ {% if profile.running %} + + {% endif %} +
+ {% if channels %} + + + {% for g in channels %}{{ groupRow(g) }}{% endfor %} +
NameSubscribers
+ {% else %} +

No channels yet.{% if not profile.running %} Start the profile first.{% endif %}

{% endif %}
@@ -144,46 +191,221 @@
+ + +

Create Group

+

+
+ + +
+ +
+ +
+ + +
+
+
+ + + +
+

Members —

+ +
+
+

Loading…

+
+
+ + + +

Message

+
+ +
+
+ +
+ + +
+
+
+ {% endblock %} diff --git a/manager/templates/settings.html b/manager/templates/settings.html new file mode 100644 index 0000000..9d44fa9 --- /dev/null +++ b/manager/templates/settings.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block title %}Settings — SimpleX Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +

Settings

+ +
+

Theme

+
+ +
+
+
+
+
+
+
+
+ Original Light + +
+
+ +
+
+
+
+
+
+
+
+ Original Dark + +
+
+ +
+
+
+
+
+
+
+
+ Matrix + +
+
+ +
+
+ + +{% endblock %}