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

@@ -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()">&#x2715;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}