Communities/openreli/matrix-wordpress.md hinzugefügt
This commit is contained in:
parent
929d3541b4
commit
aee1eefb9f
|
|
@ -0,0 +1,705 @@
|
|||
# Matrix Chat Widget – Dokumentation
|
||||
|
||||
**Projekt:** Öffentliche Leseanzeige eines Matrix-Raums auf einer WordPress-Seite
|
||||
**Homeserver:** `matrix.rpi-virtuell.de`
|
||||
**Element-Client:** `element.rpi-virtuell.de`
|
||||
**Raum-ID:** `!NQPmoqtLSjGzdtLaXO:rpi-virtuell.de`
|
||||
**Stand:** März 2026
|
||||
|
||||
---
|
||||
|
||||
## Kontext und Problemlage
|
||||
|
||||
Ziel war es, einen öffentlichen Matrix-Raum als Leseanzeige (kein Login für Besucher:innen erforderlich) in eine WordPress-Seite einzubetten. Dabei wurden folgende Wege geprüft und verworfen:
|
||||
|
||||
**Matrix Public Archive / Matrix Viewer** – wurde eingestellt.
|
||||
|
||||
**matrix-live (live.hello-matrix.net)** – funktioniert nur mit Homeservern, die Guest Access aktiviert haben.
|
||||
|
||||
**Guest Access** – auf `matrix.rpi-virtuell.de` nicht aktiviert; auf `matrix.org` seit Januar 2025 abgeschaltet.
|
||||
|
||||
**Element Web als iFrame** – `app.element.io` und die meisten selbst gehosteten Instanzen setzen `X-Frame-Options: DENY` und können nicht eingebettet werden.
|
||||
|
||||
**Gewählte Lösung:** Ein eigenständiges HTML/JS-Widget, das direkt die Matrix Client-Server API abfragt. Voraussetzung ist ein dedizierter Bot-Account mit Lesezugriff, dessen Token fest im Widget hinterlegt ist. Das ist unkritisch, da der Token nur Lesezugriff auf einen öffentlichen Raum hat.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### 1. Raum auf „world_readable" setzen
|
||||
|
||||
In Element: **Raumeinstellungen → Sicherheit & Datenschutz → „Wer kann den Verlauf lesen?" → „Jeder"**
|
||||
|
||||
Ohne diese Einstellung antwortet die API mit HTTP 403.
|
||||
|
||||
### 2. Bot-Account anlegen und Token holen
|
||||
|
||||
Einen dedizierten Account (z. B. `@openrelibot:rpi-virtuell.de`) anlegen und einmalig per API einloggen:
|
||||
|
||||
```bash
|
||||
curl -X POST https://matrix.rpi-virtuell.de/_matrix/client/v3/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"m.login.password","user":"openrelibot","password":"PASSWORT"}'
|
||||
```
|
||||
|
||||
Die Antwort enthält `access_token` – dieser wird im Widget als `TOKEN` eingetragen.
|
||||
|
||||
> **Hinweis:** Da der Token in einem Chatprotokoll sichtbar war, sollte er bei nächster Gelegenheit rotiert werden (siehe Abschnitt Token-Rotation).
|
||||
|
||||
---
|
||||
|
||||
## Einbindung in WordPress
|
||||
|
||||
Gutenberg-Editor → Block **„Benutzerdefiniertes HTML"** → vollständigen Widget-Code einfügen → Speichern.
|
||||
|
||||
Kein Plugin erforderlich. Der Code ist vollständig eigenständig (kein externes CDN, keine Abhängigkeiten).
|
||||
|
||||
---
|
||||
|
||||
## Vollständiger Code
|
||||
|
||||
```html
|
||||
<!--
|
||||
Matrix Chat Widget für rpi-virtuell.de
|
||||
Mit Thread- und Reply-Unterstützung (eingerückt + ausklappbar)
|
||||
Bearbeitete Nachrichten werden dedupliziert und mit "(bearbeitet)" markiert.
|
||||
|
||||
VORAUSSETZUNG: Raumeinstellungen → Sicherheit → "Verlauf: Jeder" (world_readable)
|
||||
EINBINDUNG: Gutenberg-Block "Benutzerdefiniertes HTML"
|
||||
-->
|
||||
|
||||
<div id="matrix-widget-wrapper">
|
||||
<div id="matrix-chat-header">
|
||||
<div id="matrix-header-left">
|
||||
<div id="matrix-room-avatar" class="matrix-room-avatar-placeholder"></div>
|
||||
<span id="matrix-room-name">Chat wird geladen…</span>
|
||||
</div>
|
||||
<span id="matrix-status" class="matrix-dot loading"></span>
|
||||
</div>
|
||||
<div id="matrix-messages"></div>
|
||||
<div id="matrix-chat-footer">
|
||||
<a href="https://element.rpi-virtuell.de/#/room/!NQPmoqtLSjGzdtLaXO:rpi-virtuell.de"
|
||||
target="_blank" rel="noopener">
|
||||
✏️ Mitschreiben in Element
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#matrix-widget-wrapper {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 700px;
|
||||
margin: 1em 0;
|
||||
background: #fff;
|
||||
}
|
||||
#matrix-chat-header {
|
||||
background: #0dbd8b;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#matrix-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.matrix-room-avatar-placeholder {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#matrix-room-avatar img {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.matrix-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.matrix-dot.loading { background: #fff8; animation: pulse 1s infinite; }
|
||||
.matrix-dot.ok { background: #4caf50; }
|
||||
.matrix-dot.error { background: #f44336; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
|
||||
#matrix-messages {
|
||||
height: 420px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* ── Nachricht (Top-Level) ─────────────────────────── */
|
||||
.matrix-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
animation: fadeIn .2s ease;
|
||||
}
|
||||
@keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
|
||||
|
||||
.matrix-avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
}
|
||||
.matrix-avatar img {
|
||||
width: 36px; height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
.matrix-bubble {
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 0 10px 10px 10px;
|
||||
padding: 6px 10px;
|
||||
max-width: 85%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.matrix-sender {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: #0dbd8b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.matrix-text {
|
||||
font-size: 0.9em;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.matrix-time {
|
||||
font-size: 0.7em;
|
||||
color: #aaa;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.matrix-edited {
|
||||
font-style: italic;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* ── Klassische Reply-Vorschau (zitierter Text) ────── */
|
||||
.matrix-reply-preview {
|
||||
font-size: 0.78em;
|
||||
color: #888;
|
||||
border-left: 3px solid #0dbd8b;
|
||||
padding: 2px 6px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #f0faf6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Thread-Toggle-Button ──────────────────────────── */
|
||||
.matrix-thread-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
font-size: 0.75em;
|
||||
color: #0dbd8b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.matrix-thread-toggle:hover { text-decoration: underline; }
|
||||
.matrix-thread-toggle .arrow { display: inline-block; transition: transform .2s; }
|
||||
.matrix-thread-toggle.open .arrow { transform: rotate(90deg); }
|
||||
|
||||
/* ── Thread-Container (eingerückte Replies) ────────── */
|
||||
.matrix-thread {
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
margin-left: 46px; /* avatar-breite + gap */
|
||||
border-left: 2px solid #d0f0e6;
|
||||
padding-left: 10px;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.matrix-thread.open { display: flex; }
|
||||
|
||||
/* Thread-Replies: kleinere Avatare */
|
||||
.matrix-thread .matrix-msg { gap: 7px; }
|
||||
.matrix-thread .matrix-avatar {
|
||||
width: 26px; height: 26px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.matrix-thread .matrix-avatar img { width: 26px; height: 26px; }
|
||||
.matrix-thread .matrix-bubble {
|
||||
border-radius: 0 8px 8px 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.matrix-thread .matrix-sender { font-size: 0.72em; }
|
||||
.matrix-thread .matrix-text { font-size: 0.85em; }
|
||||
.matrix-thread .matrix-time { font-size: 0.68em; }
|
||||
|
||||
/* ── Eingerückte klassische Reply (kein Thread) ────── */
|
||||
.matrix-reply-indent {
|
||||
margin-left: 46px;
|
||||
border-left: 2px solid #e0e0e0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.matrix-reply-indent .matrix-avatar { width: 26px; height: 26px; font-size: 0.7em; }
|
||||
.matrix-reply-indent .matrix-avatar img { width: 26px; height: 26px; }
|
||||
|
||||
/* ── Footer ────────────────────────────────────────── */
|
||||
#matrix-chat-footer {
|
||||
padding: 8px 16px;
|
||||
background: #f5f5f5;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 0.82em;
|
||||
text-align: right;
|
||||
}
|
||||
#matrix-chat-footer a { color: #0dbd8b; text-decoration: none; }
|
||||
#matrix-chat-footer a:hover { text-decoration: underline; }
|
||||
.matrix-error-msg {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const HOMESERVER = 'https://matrix.rpi-virtuell.de';
|
||||
const ROOM_ID = '!NQPmoqtLSjGzdtLaXO:rpi-virtuell.de';
|
||||
const LIMIT = 40;
|
||||
const POLL_SEC = 30;
|
||||
const TOKEN = 'syt_b3BlbnJlbGlib3Q_SdFwxHEkUplXymuZJcPD_1Vvd2k';
|
||||
const HEADERS = { 'Authorization': 'Bearer ' + TOKEN };
|
||||
|
||||
const messagesEl = document.getElementById('matrix-messages');
|
||||
const statusEl = document.getElementById('matrix-status');
|
||||
const nameEl = document.getElementById('matrix-room-name');
|
||||
const roomAvatarEl = document.getElementById('matrix-room-avatar');
|
||||
const avatarCache = {};
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────
|
||||
|
||||
const COLORS = ['#0dbd8b','#5c56f5','#e06c75','#d19a66','#61afef','#56b6c2','#c678dd'];
|
||||
function colorFor(sender) {
|
||||
let h = 0;
|
||||
for (let c of sender) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
|
||||
return COLORS[Math.abs(h) % COLORS.length];
|
||||
}
|
||||
function localpart(mxid) {
|
||||
const m = mxid.match(/^@([^:]+):/);
|
||||
return m ? m[1] : mxid;
|
||||
}
|
||||
function formatTime(ts) {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function mxcToHttp(mxc) {
|
||||
if (!mxc || !mxc.startsWith('mxc://')) return null;
|
||||
return HOMESERVER + '/_matrix/media/v3/download/' + mxc.slice(6);
|
||||
}
|
||||
|
||||
// ── Raum-Metadaten ───────────────────────────────────────────
|
||||
|
||||
async function fetchRoomAvatar() {
|
||||
try {
|
||||
const res = await fetch(HOMESERVER + '/_matrix/client/v3/rooms/' + encodeURIComponent(ROOM_ID) + '/state/m.room.avatar/', { headers: HEADERS });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const http = mxcToHttp(data.url);
|
||||
if (http) roomAvatarEl.innerHTML = '<img src="' + http + '" alt="Raumlogo">';
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
async function fetchRoomName() {
|
||||
try {
|
||||
const res = await fetch(HOMESERVER + '/_matrix/client/v3/rooms/' + encodeURIComponent(ROOM_ID) + '/state/m.room.name/', { headers: HEADERS });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.name) nameEl.textContent = data.name;
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
// ── Benutzer-Avatare ─────────────────────────────────────────
|
||||
|
||||
async function getUserAvatarUrl(mxid) {
|
||||
if (mxid in avatarCache) return avatarCache[mxid];
|
||||
try {
|
||||
const res = await fetch(HOMESERVER + '/_matrix/client/v3/profile/' + encodeURIComponent(mxid) + '/avatar_url', { headers: HEADERS });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const http = mxcToHttp(data.avatar_url);
|
||||
avatarCache[mxid] = http;
|
||||
return http;
|
||||
}
|
||||
} catch(_) {}
|
||||
avatarCache[mxid] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── DOM-Hilfsfunktion: Nachrichtenzeile bauen ─────────────────
|
||||
|
||||
function buildMsgEl(e, small) {
|
||||
const sender = localpart(e.sender);
|
||||
const initials = escapeHtml(sender.slice(0, 2));
|
||||
const color = colorFor(e.sender);
|
||||
const avatarUrl = avatarCache[e.sender];
|
||||
const size = small ? 26 : 36;
|
||||
|
||||
const avatarInner = avatarUrl
|
||||
? '<img src="' + avatarUrl + '" alt="' + initials + '" width="' + size + '" height="' + size + '" onerror="this.parentElement.style.background=\'' + color + '\';this.outerHTML=\'' + initials + '\'">'
|
||||
: initials;
|
||||
const avatarStyle = avatarUrl ? '' : 'background:' + color + ';';
|
||||
|
||||
// Reply-Vorschau: zitierter Text aus m.in_reply_to
|
||||
// Element bettet Zitat als "> Text\n\nAntwort" in den body ein
|
||||
let replyPreview = '';
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
const isReply = rel && rel['m.in_reply_to'] && (!rel.rel_type || rel.rel_type !== 'm.thread');
|
||||
if (isReply) {
|
||||
const body = e.content.body || '';
|
||||
const parts = body.split('\n\n');
|
||||
if (parts.length > 1) {
|
||||
const quoted = parts.slice(0, -1).join(' ').replace(/^>?\s*/gm, '').trim();
|
||||
if (quoted) {
|
||||
replyPreview = '<div class="matrix-reply-preview">' + escapeHtml(quoted.slice(0, 80)) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nachrichtentext: bei Replies nur den Teil nach dem Zitat
|
||||
let displayBody = e.content.body || '';
|
||||
if (isReply) {
|
||||
const parts = displayBody.split('\n\n');
|
||||
if (parts.length > 1) displayBody = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'matrix-msg';
|
||||
div.setAttribute('data-event-id', e.event_id);
|
||||
div.innerHTML =
|
||||
'<div class="matrix-avatar" style="' + avatarStyle + '">' + avatarInner + '</div>' +
|
||||
'<div class="matrix-bubble">' +
|
||||
replyPreview +
|
||||
'<div class="matrix-sender">' + escapeHtml(sender) + '</div>' +
|
||||
'<div class="matrix-text">' + escapeHtml(displayBody) + '</div>' +
|
||||
'<div class="matrix-time">' + formatTime(e.origin_server_ts) +
|
||||
(e._edited ? ' <span class="matrix-edited">(bearbeitet)</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────
|
||||
|
||||
async function renderMessages(events) {
|
||||
messagesEl.innerHTML = '';
|
||||
|
||||
// ── Edit-Deduplizierung ─────────────────────────────────────
|
||||
// m.replace-Events (rel_type: "m.replace") enthalten den neuen Text in
|
||||
// content['m.new_content']. Pro Original-ID wird das neueste Replace-Event
|
||||
// ermittelt, dessen Inhalt dann das Original überschreibt. Die Replace-Events
|
||||
// selbst werden aus der Anzeigeliste herausgefiltert.
|
||||
const latestEdit = {};
|
||||
events.forEach(function(e) {
|
||||
if (e.type !== 'm.room.message') return;
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
if (rel && rel.rel_type === 'm.replace' && rel.event_id) {
|
||||
const orig = rel.event_id;
|
||||
if (!latestEdit[orig] || e.origin_server_ts > latestEdit[orig].origin_server_ts) {
|
||||
latestEdit[orig] = e;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const patchedEvents = events
|
||||
.filter(function(e) {
|
||||
if (e.type !== 'm.room.message') return false;
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
if (rel && rel.rel_type === 'm.replace') return false;
|
||||
return true;
|
||||
})
|
||||
.map(function(e) {
|
||||
const edit = latestEdit[e.event_id];
|
||||
if (!edit) return e;
|
||||
const newContent = edit.content['m.new_content'] || {};
|
||||
return Object.assign({}, e, {
|
||||
content: Object.assign({}, newContent, { '_edited': true }),
|
||||
_edited: true
|
||||
});
|
||||
});
|
||||
|
||||
const msgs = patchedEvents.slice(-LIMIT);
|
||||
|
||||
if (!msgs.length) {
|
||||
messagesEl.innerHTML = '<p class="matrix-error-msg">Noch keine Nachrichten.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Avatare parallel vorladen
|
||||
const senders = [...new Set(msgs.map(function(e) { return e.sender; }))];
|
||||
await Promise.all(senders.map(getUserAvatarUrl));
|
||||
|
||||
// Nachrichten aufteilen: Top-Level vs. Thread-Kinder
|
||||
const byId = {};
|
||||
const threads = {};
|
||||
const topLevel = [];
|
||||
|
||||
msgs.forEach(function(e) { byId[e.event_id] = e; });
|
||||
|
||||
msgs.forEach(function(e) {
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
if (rel && rel.rel_type === 'm.thread' && rel.event_id) {
|
||||
const rootId = rel.event_id;
|
||||
if (!threads[rootId]) threads[rootId] = [];
|
||||
threads[rootId].push(e);
|
||||
} else {
|
||||
topLevel.push(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Top-Level rendern
|
||||
topLevel.forEach(function(e) {
|
||||
const wrapper = document.createElement('div');
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
const isClassicReply = rel && rel['m.in_reply_to'] && (!rel.rel_type || rel.rel_type !== 'm.thread');
|
||||
if (isClassicReply) wrapper.className = 'matrix-reply-indent';
|
||||
|
||||
wrapper.appendChild(buildMsgEl(e, isClassicReply));
|
||||
|
||||
const children = threads[e.event_id];
|
||||
if (children && children.length > 0) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'matrix-thread-toggle';
|
||||
toggle.innerHTML = '<span class="arrow">▶</span> ' + children.length +
|
||||
' Antwort' + (children.length > 1 ? 'en' : '') + ' im Thread';
|
||||
|
||||
const threadEl = document.createElement('div');
|
||||
threadEl.className = 'matrix-thread';
|
||||
children.forEach(function(child) { threadEl.appendChild(buildMsgEl(child, true)); });
|
||||
|
||||
toggle.addEventListener('click', function() {
|
||||
const open = threadEl.classList.toggle('open');
|
||||
toggle.classList.toggle('open', open);
|
||||
toggle.querySelector('.arrow').textContent = open ? '▼' : '▶';
|
||||
});
|
||||
|
||||
const bubble = wrapper.querySelector('.matrix-bubble');
|
||||
if (bubble) bubble.appendChild(toggle);
|
||||
wrapper.appendChild(threadEl);
|
||||
}
|
||||
|
||||
messagesEl.appendChild(wrapper);
|
||||
});
|
||||
|
||||
// Thread-Kinder deren Root außerhalb des Ladefensters liegt
|
||||
msgs.forEach(function(e) {
|
||||
const rel = e.content && e.content['m.relates_to'];
|
||||
if (rel && rel.rel_type === 'm.thread' && rel.event_id && !byId[rel.event_id]) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'matrix-reply-indent';
|
||||
wrap.appendChild(buildMsgEl(e, true));
|
||||
messagesEl.appendChild(wrap);
|
||||
}
|
||||
});
|
||||
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Nachrichten laden ─────────────────────────────────────────
|
||||
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
const url = HOMESERVER + '/_matrix/client/v3/rooms/' + encodeURIComponent(ROOM_ID) +
|
||||
'/messages?dir=b&limit=' + (LIMIT * 3);
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
|
||||
if (res.status === 403) {
|
||||
messagesEl.innerHTML = '<p class="matrix-error-msg">Raum nicht öffentlich lesbar.<br>' +
|
||||
'Element: Raumeinstellungen → Sicherheit → Verlauf: Jeder</p>';
|
||||
statusEl.className = 'matrix-dot error';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
|
||||
const data = await res.json();
|
||||
await renderMessages((data.chunk || []).reverse());
|
||||
statusEl.className = 'matrix-dot ok';
|
||||
} catch(err) {
|
||||
statusEl.className = 'matrix-dot error';
|
||||
if (!messagesEl.querySelector('.matrix-error-msg')) {
|
||||
messagesEl.innerHTML = '<p class="matrix-error-msg">Verbindung fehlgeschlagen (' + err.message + ')</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchRoomName();
|
||||
fetchRoomAvatar();
|
||||
fetchMessages();
|
||||
setInterval(fetchMessages, POLL_SEC * 1000);
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technische Dokumentation
|
||||
|
||||
### Architektur
|
||||
|
||||
Das Widget ist eine einzelne, vollständig eigenständige HTML-Datei ohne externe Abhängigkeiten. Es kommuniziert direkt mit der Matrix Client-Server API (v3) des Homeservers per `fetch()`.
|
||||
|
||||
```
|
||||
Browser → fetch() → matrix.rpi-virtuell.de/_matrix/client/v3/...
|
||||
→ /_matrix/media/v3/download/... (Bilder)
|
||||
```
|
||||
|
||||
### Verwendete API-Endpunkte
|
||||
|
||||
| Endpunkt | Zweck |
|
||||
|---|---|
|
||||
| `GET /rooms/{roomId}/state/m.room.name/` | Raumname laden |
|
||||
| `GET /rooms/{roomId}/state/m.room.avatar/` | Raumlogo laden (mxc-URL) |
|
||||
| `GET /rooms/{roomId}/messages?dir=b&limit=N` | Letzte Nachrichten abrufen |
|
||||
| `GET /profile/{userId}/avatar_url` | Benutzer-Avatar laden (mxc-URL) |
|
||||
| `GET /_matrix/media/v3/download/{server}/{id}` | Mediendatei (Bild) abrufen |
|
||||
|
||||
Alle Requests senden den `Authorization: Bearer {TOKEN}` Header, da der Homeserver auch für world-readable Räume Authentifizierung verlangt (HTTP 401 ohne Token).
|
||||
|
||||
### Konfigurationsparameter
|
||||
|
||||
```javascript
|
||||
const HOMESERVER = 'https://matrix.rpi-virtuell.de'; // Matrix-Homeserver
|
||||
const ROOM_ID = '!NQPmoqtLSjGzdtLaXO:rpi-virtuell.de'; // Raum-ID
|
||||
const LIMIT = 40; // Anzahl angezeigter Top-Level-Nachrichten
|
||||
const POLL_SEC = 30; // Polling-Intervall in Sekunden
|
||||
const TOKEN = '...'; // Access Token des Bot-Accounts
|
||||
```
|
||||
|
||||
### Nachrichten-Typen und Darstellung
|
||||
|
||||
Das Widget unterscheidet vier Typen von Events anhand des `m.relates_to`-Feldes:
|
||||
|
||||
**Normale Nachricht** – kein `m.relates_to` → wird auf Top-Level gerendert.
|
||||
|
||||
**Bearbeitete Nachricht** (`rel_type: "m.replace"`) → wird nicht als separate Nachricht angezeigt. Stattdessen überschreibt das neueste Replace-Event den Inhalt der Originalnachricht. In der Zeitzeile erscheint kursiv *(bearbeitet)*.
|
||||
|
||||
**Klassische Reply** (`m.in_reply_to`, kein `rel_type: m.thread`) → wird um 46 px eingerückt dargestellt, mit grauer linker Borderlinie und einer grünen Zitiervorschau des Originaltexts. Element bettet den zitierten Text als `> Zitat\n\nAntwort` in den `body` ein – das Widget extrahiert dieses Muster per `split('\n\n')`.
|
||||
|
||||
**Thread-Reply** (`rel_type: "m.thread"`, `event_id` = Root-Event-ID) → wird unter der Root-Nachricht gesammelt und erst nach Klick auf den Toggle-Button sichtbar. Der aufgeklappte Thread-Container hat eine grüne linke Borderlinie und kleinere Avatare (26 px statt 36 px).
|
||||
|
||||
### Rendering-Pipeline
|
||||
|
||||
```
|
||||
fetchMessages()
|
||||
→ API: /messages?dir=b&limit=120
|
||||
→ events.reverse() // chronologisch sortieren
|
||||
→ Edit-Deduplizierung:
|
||||
latestEdit{} sammeln (m.replace)
|
||||
Replace-Events herausfiltern
|
||||
Originale mit m.new_content patchen + _edited:true
|
||||
→ patchedEvents.slice(-40) // letzte 40 behalten
|
||||
→ getUserAvatarUrl() parallel // Avatare vorladen
|
||||
→ Sortierung: byId{}, threads{}, topLevel[]
|
||||
→ topLevel.forEach → buildMsgEl()
|
||||
→ threads[e.event_id] → Toggle + threadEl
|
||||
→ Orphan-Thread-Kinder anhängen
|
||||
```
|
||||
|
||||
Das Ladefenster (`limit = LIMIT * 3 = 120`) ist größer als die angezeigten 40 Nachrichten, um sicherzustellen, dass zu den angezeigten Top-Level-Nachrichten auch ihre zugehörigen Edit- und Thread-Events im selben API-Response enthalten sind.
|
||||
|
||||
### mxc-URLs
|
||||
|
||||
Matrix speichert Mediendateien intern als `mxc://server/id`. Die Funktion `mxcToHttp()` konvertiert diese in reguläre HTTPS-URLs:
|
||||
|
||||
```
|
||||
mxc://rpi-virtuell.de/AbcXyz
|
||||
→ https://matrix.rpi-virtuell.de/_matrix/media/v3/download/rpi-virtuell.de/AbcXyz
|
||||
```
|
||||
|
||||
### Avatar-Caching
|
||||
|
||||
Um bei jedem Polling-Zyklus unnötige API-Aufrufe zu vermeiden, werden User-Avatare in einem In-Memory-Objekt `avatarCache` gespeichert (`mxid → URL | null`). Pro Rendering-Durchlauf werden alle einzigartigen Absender parallel vorgeladen (`Promise.all`), bevor die Nachrichten gerendert werden.
|
||||
|
||||
### Fallback-Logik für Avatare
|
||||
|
||||
Ist kein Profilbild hinterlegt oder schlägt das Laden fehl (`onerror`), zeigt das Widget einen farbigen Kreis mit den ersten zwei Buchstaben des Localparts. Die Farbe wird deterministisch per Hash-Funktion aus der vollständigen MXID berechnet – dieselbe Person hat immer dieselbe Farbe.
|
||||
|
||||
### XSS-Schutz
|
||||
|
||||
Alle aus der API stammenden Texte (Absendername, Nachrichtentext, Zitiervorschau) werden durch `escapeHtml()` bereinigt, bevor sie per `innerHTML` eingefügt werden. Bilder werden nur als `<img src="...">` eingebettet, nie als HTML aus dem Event-Content übernommen.
|
||||
|
||||
### Statusanzeige
|
||||
|
||||
Der kleine Punkt oben rechts im Header zeigt den Verbindungsstatus:
|
||||
|
||||
- 🟡 pulsierend – Ladevorgang
|
||||
- 🟢 grün – letzter API-Aufruf erfolgreich
|
||||
- 🔴 rot – Fehler (403 Forbidden oder Netzwerkfehler)
|
||||
|
||||
### Polling statt WebSocket
|
||||
|
||||
Das Widget verwendet Polling alle 30 Sekunden statt einer persistenten WebSocket-Verbindung (`/sync`). Begründung: WordPress-Seiten haben oft viele gleichzeitige Besucher:innen. Ein dauerhafter Sync-Kanal pro Seitenaufruf würde den Homeserver stark belasten. Polling ist hier ausreichend und deutlich ressourcenschonender.
|
||||
|
||||
---
|
||||
|
||||
## Anpassungsmöglichkeiten
|
||||
|
||||
**Höhe des Chat-Bereichs** – CSS: `#matrix-messages { height: 420px; }`
|
||||
|
||||
**Anzahl der Nachrichten** – `const LIMIT = 40;`
|
||||
|
||||
**Polling-Frequenz** – `const POLL_SEC = 30;` (nicht unter 10 setzen)
|
||||
|
||||
**Farbe des Headers** – `#matrix-chat-header { background: #0dbd8b; }` (Matrix-Grün)
|
||||
|
||||
**„Mitschreiben"-Link** – URL im `<a href="...">` im HTML-Teil anpassen
|
||||
|
||||
---
|
||||
|
||||
## Token-Rotation
|
||||
|
||||
Da der Token im Klartext im Code steht, sollte er bei Bedarf rotiert werden:
|
||||
|
||||
```bash
|
||||
# Alten Token invalidieren
|
||||
curl -X POST https://matrix.rpi-virtuell.de/_matrix/client/v3/logout \
|
||||
-H "Authorization: Bearer ALTERTOKEN"
|
||||
|
||||
# Neuen Token holen
|
||||
curl -X POST https://matrix.rpi-virtuell.de/_matrix/client/v3/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"m.login.password","user":"openrelibot","password":"PASSWORT"}'
|
||||
```
|
||||
|
||||
Neuen `access_token` im Code als `TOKEN` einsetzen und WordPress-Seite aktualisieren.
|
||||
Loading…
Reference in New Issue