Files
simplex-manager/manager/templates/chat.html
Jon c1bb9cb955 Add chat actions, channels/groups mgmt, directory + deadmans bots, network status
Profiles/bots (profiles.py, main.py):
- Surface real send errors; fix group send and member-count staleness (refresh on view)
- Group/channel actions: join, leave, owner delete; consistent member counts
- Contacts: always-visible Chat, plus Clear chat and Delete contact
- Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config
- Deadmans bot: check-in window, trigger message, recipients, owner
- Directory bot: add-to-group registration, super-user /approve /reject /list, search,
  publishes listing.json in the website schema (directory.py registry module)
- Profile edit (name/bio/avatar) + avatars on list pages
- Global status + /network page (operators, SMP/XFTP servers) and Settings network info

UI (templates):
- Chat rooms; collapsible left sidebar with notifications + network widget
- Per-directory-bot website generated on creation (name substituted)
- Matrix theme; copy/hyperlink addresses; site footer

Ignore runtime bot state and generated directory sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:26:16 +01:00

155 lines
5.6 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %}
{% block head %}
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 140px); min-height: 400px;
background: var(--card); border-radius: 10px; box-shadow: var(--shadow);
overflow: hidden;
}
.chat-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.chat-head .title { font-weight: 700; font-size: 16px; }
.chat-log {
flex: 1; overflow-y: auto; padding: 18px;
display: flex; flex-direction: column; gap: 8px;
}
.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;
}
.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.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; }
.chat-compose {
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
}
.chat-compose textarea { resize: none; height: 42px; }
.chat-empty { text-align: center; color: var(--muted); margin: auto; font-size: 14px; }
.chat-banner { padding: 8px 18px; font-size: 12px; color: var(--muted);
background: var(--bg); border-bottom: 1px solid var(--border); }
</style>
{% endblock %}
{% block content %}
<div class="flex gap-8" style="margin-bottom:16px;">
<a href="/profile/{{ profile.id }}" class="muted" style="text-decoration:none;">← {{ profile.name }}</a>
<span class="muted">/</span>
<strong>{{ chat_name }}</strong>
<span class="tag">{{ 'channel' if is_channel else chat_type }}</span>
</div>
<div class="chat-wrap">
<div class="chat-head">
<span class="title">{{ chat_name }}</span>
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
onclick="loadMessages(true)">↻ Refresh</button>
</div>
{% if is_channel %}
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
{% endif %}
<div class="chat-log" id="chat-log">
{% if not running %}
<div class="chat-empty">Profile is stopped. Start it to load messages.</div>
{% else %}
<div class="chat-empty" id="chat-loading">Loading messages…</div>
{% endif %}
</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 %}
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg();}"></textarea>
<button class="btn btn-primary" onclick="sendMsg()" {% if not running %}disabled{% endif %}>Send</button>
</div>
</div>
<script>
const PROFILE_ID = {{ profile.id }};
const CHAT_TYPE = '{{ chat_type }}';
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
function fmtTs(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
}
function render(messages) {
const log = document.getElementById('chat-log');
const sig = messages.map(m => m.id).join(',');
if (sig === lastIds) return; // nothing new
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
lastIds = sig;
if (!messages.length) {
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
return;
}
log.innerHTML = messages.map(m => {
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
}).join('');
if (atBottom) log.scrollTop = log.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadMessages(force) {
if (!RUNNING) return;
try {
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/messages?count=80`, {
headers: {'X-Token': _token()},
});
if (!resp.ok) return;
const data = await resp.json();
if (force) lastIds = '';
render(data.messages || []);
} catch(e) {}
}
async function sendMsg() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
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}),
});
const data = await resp.json();
if (!data.ok) {
input.value = text; // restore on failure
alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
return;
}
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
}
if (RUNNING) {
loadMessages(true);
setInterval(loadMessages, 3000); // live updates via polling
}
</script>
{% endblock %}