From 740a8a2aab712700f3078d80c3ba5dd6d026effd Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 19 Jun 2026 17:09:20 +0200 Subject: [PATCH] Add initial implementation of Matrix chat widget with Flask blueprint - Create .gitignore to exclude Python-related files - Implement chat blueprint in __init__.py - Add chat HTML template with input and message display - Create CSS for chat styling - Develop JavaScript for chat functionality and Matrix API integration - Configure pyproject.toml for project metadata and dependencies --- .gitignore | 6 + matrix_iframe/__init__.py | 50 +++ .../static/matrix_iframe/matrix-chat.css | 144 ++++++++ .../static/matrix_iframe/matrix-chat.js | 311 ++++++++++++++++++ .../templates/matrix_iframe/chat.html | 19 ++ pyproject.toml | 17 + 6 files changed, 547 insertions(+) create mode 100644 .gitignore create mode 100644 matrix_iframe/__init__.py create mode 100644 matrix_iframe/static/matrix_iframe/matrix-chat.css create mode 100644 matrix_iframe/static/matrix_iframe/matrix-chat.js create mode 100644 matrix_iframe/templates/matrix_iframe/chat.html create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..396c7d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/matrix_iframe/__init__.py b/matrix_iframe/__init__.py new file mode 100644 index 0000000..2e22afb --- /dev/null +++ b/matrix_iframe/__init__.py @@ -0,0 +1,50 @@ +from flask import Blueprint, render_template, current_app, redirect, url_for, request + +blueprint = Blueprint( + 'matrix_iframe', + __name__, + template_folder='templates', + static_folder='static', + static_url_path='/static/matrix_iframe', +) + + +def init_app(app, server_url='', room_alias='', base_template='layout.html', login_endpoint=None): + """ + Register the Matrix chat blueprint with a Flask app. + + Args: + server_url: Matrix homeserver base URL, e.g. "https://matrix.sticknife.com" + room_alias: Matrix room alias, e.g. "#sticknife-library:matrix.sticknife.com" + base_template: Jinja2 base template to extend, default "layout.html" + login_endpoint: Flask endpoint to redirect unauthenticated users to, + e.g. "web.login". If None, no auth guard is applied. + """ + app.config.setdefault('MATRIX_SERVER_URL', server_url) + app.config.setdefault('MATRIX_ROOM_ALIAS', room_alias) + app.config.setdefault('MATRIX_CHAT_BASE', base_template) + app.config.setdefault('MATRIX_CHAT_LOGIN_ENDPOINT', login_endpoint) + app.register_blueprint(blueprint) + + +@blueprint.before_request +def _auth_guard(): + login_endpoint = current_app.config.get('MATRIX_CHAT_LOGIN_ENDPOINT') + if not login_endpoint: + return + try: + from flask_login import current_user + if not current_user.is_authenticated: + return redirect(url_for(login_endpoint, next=request.url)) + except ImportError: + pass + + +@blueprint.route('/chat') +def chat(): + return render_template( + 'matrix_iframe/chat.html', + matrix_server=current_app.config.get('MATRIX_SERVER_URL', ''), + matrix_room_alias=current_app.config.get('MATRIX_ROOM_ALIAS', ''), + base_template=current_app.config.get('MATRIX_CHAT_BASE', 'layout.html'), + ) diff --git a/matrix_iframe/static/matrix_iframe/matrix-chat.css b/matrix_iframe/static/matrix_iframe/matrix-chat.css new file mode 100644 index 0000000..2579ee3 --- /dev/null +++ b/matrix_iframe/static/matrix_iframe/matrix-chat.css @@ -0,0 +1,144 @@ +#matrix-chat-root { + display: flex; + flex-direction: column; + height: calc(100vh - 120px); + padding: 1rem; + gap: 0.75rem; +} + +.matrix-status { + font-size: 0.875rem; + color: #aaa; + min-height: 1.25rem; +} + +.matrix-status-error { + color: #e74c3c; +} + +/* ── Message list ───────────────────────────────────────────────────────── */ + +.matrix-messages { + flex: 1; + overflow-y: auto; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.matrix-message { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: nowrap; + font-size: 0.9rem; + line-height: 1.5; + min-width: 0; +} + +.matrix-sender { + font-weight: 600; + color: #7ecfff; + white-space: nowrap; + flex-shrink: 0; +} + +.matrix-sender::after { + content: ':'; +} + +.matrix-body { + color: #ddd; + word-break: break-word; + flex: 1; + min-width: 0; +} + +.matrix-time { + font-size: 0.72rem; + color: #555; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-end; + padding-bottom: 1px; +} + +/* ── Input bar ──────────────────────────────────────────────────────────── */ + +.matrix-input-bar { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.matrix-input { + flex: 1; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #ddd; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s; +} + +.matrix-input:focus { + border-color: rgba(126, 207, 255, 0.4); +} + +.matrix-input::placeholder { + color: #555; +} + +.matrix-send-btn { + background: rgba(126, 207, 255, 0.12); + border: 1px solid rgba(126, 207, 255, 0.3); + border-radius: 6px; + color: #7ecfff; + padding: 0.5rem 1.25rem; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.matrix-send-btn:hover { + background: rgba(126, 207, 255, 0.22); + border-color: rgba(126, 207, 255, 0.5); +} + +.matrix-send-btn:active { + background: rgba(126, 207, 255, 0.3); +} + +/* ── Light theme fallback (when host app uses a light base) ─────────────── */ + +@media (prefers-color-scheme: light) { + .matrix-messages { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.1); + } + .matrix-sender { color: #1a6fa8; } + .matrix-body { color: #222; } + .matrix-time { color: #aaa; } + .matrix-input { + background: #fff; + border-color: #ccc; + color: #222; + } + .matrix-input:focus { border-color: #1a6fa8; } + .matrix-input::placeholder { color: #aaa; } + .matrix-send-btn { + background: rgba(26, 111, 168, 0.1); + border-color: rgba(26, 111, 168, 0.4); + color: #1a6fa8; + } + .matrix-send-btn:hover { + background: rgba(26, 111, 168, 0.18); + } +} diff --git a/matrix_iframe/static/matrix_iframe/matrix-chat.js b/matrix_iframe/static/matrix_iframe/matrix-chat.js new file mode 100644 index 0000000..0fe7dd3 --- /dev/null +++ b/matrix_iframe/static/matrix_iframe/matrix-chat.js @@ -0,0 +1,311 @@ +'use strict'; + +(function () { + const root = document.getElementById('matrix-chat-root'); + const SERVER = (root.dataset.matrixServer || '').replace(/\/$/, ''); + const ROOM_ALIAS = root.dataset.matrixRoom || ''; + const CREDS_KEY = 'matrix_chat_creds_' + SERVER; + + let accessToken = null; + let userId = null; + let roomId = null; + let syncToken = null; + let syncActive = false; + let txnCounter = 0; + + const messagesEl = document.getElementById('matrix-messages'); + const statusEl = document.getElementById('matrix-chat-status'); + const inputEl = document.getElementById('matrix-input'); + const sendBtn = document.getElementById('matrix-send'); + + // ── Utilities ──────────────────────────────────────────────────────────── + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function setStatus(msg, isError) { + statusEl.textContent = msg; + statusEl.className = 'matrix-status' + (isError ? ' matrix-status-error' : ''); + } + + function clearStatus() { + statusEl.textContent = ''; + statusEl.className = 'matrix-status'; + } + + function senderLocalpart(mxid) { + const m = String(mxid).match(/^@([^:]+):/); + return m ? m[1] : mxid; + } + + function formatTime(ts) { + if (!ts) return ''; + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + // ── Credentials ────────────────────────────────────────────────────────── + + function loadCreds() { + try { + const stored = JSON.parse(localStorage.getItem(CREDS_KEY) || 'null'); + if (stored && stored.access_token) { + accessToken = stored.access_token; + userId = stored.user_id; + return true; + } + } catch (_) {} + return false; + } + + function saveCreds() { + localStorage.setItem(CREDS_KEY, JSON.stringify({ + access_token: accessToken, + user_id: userId, + })); + } + + function clearCreds() { + localStorage.removeItem(CREDS_KEY); + accessToken = null; + userId = null; + } + + // ── Matrix API ─────────────────────────────────────────────────────────── + + async function matrixFetch(method, path, body, params) { + const url = new URL(SERVER + path); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); + } + const opts = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (accessToken) opts.headers['Authorization'] = 'Bearer ' + accessToken; + if (body !== null && body !== undefined) opts.body = JSON.stringify(body); + + const resp = await fetch(url.toString(), opts); + + if (resp.status === 401) { + clearCreds(); + beginSSO(); + throw new Error('Session expired — redirecting to login'); + } + + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'HTTP ' + resp.status); + return data; + } + + // ── Auth ───────────────────────────────────────────────────────────────── + + function beginSSO() { + // Strip any existing query string so we get a clean callback URL + const redirectUrl = window.location.origin + window.location.pathname; + window.location.href = + SERVER + '/_matrix/client/v3/login/sso/redirect?redirectUrl=' + + encodeURIComponent(redirectUrl); + } + + async function exchangeLoginToken(token) { + setStatus('Authenticating…'); + const data = await matrixFetch('POST', '/_matrix/client/v3/login', { + type: 'm.login.token', + token, + initial_device_display_name: 'Sticknife Library Chat', + }); + accessToken = data.access_token; + userId = data.user_id; + saveCreds(); + // Remove loginToken from the URL without triggering a reload + window.history.replaceState({}, '', window.location.pathname); + } + + // ── Room ───────────────────────────────────────────────────────────────── + + async function resolveRoom() { + const data = await matrixFetch( + 'GET', + '/_matrix/client/v3/directory/room/' + encodeURIComponent(ROOM_ALIAS), + ); + roomId = data.room_id; + } + + async function joinRoom() { + try { + await matrixFetch('POST', '/_matrix/client/v3/join/' + encodeURIComponent(roomId), {}); + } catch (_) { + // Already joined is fine; any real error surfaces during sync + } + } + + // ── Rendering ──────────────────────────────────────────────────────────── + + function renderEvent(event) { + if (event.type !== 'm.room.message') return; + if (!event.content || event.content.msgtype !== 'm.text') return; + + const el = document.createElement('div'); + el.className = 'matrix-message'; + el.innerHTML = + '' + escapeHtml(senderLocalpart(event.sender)) + '' + + '' + escapeHtml(event.content.body) + '' + + '' + escapeHtml(formatTime(event.origin_server_ts)) + ''; + messagesEl.appendChild(el); + } + + function isScrolledToBottom() { + return messagesEl.scrollHeight - messagesEl.scrollTop <= messagesEl.clientHeight + 60; + } + + function scrollToBottom() { + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + async function loadHistory() { + const data = await matrixFetch( + 'GET', + '/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages', + null, + { dir: 'b', limit: '50' }, + ); + // /messages?dir=b returns newest-first; reverse for chronological display + const events = (data.chunk || []).reverse(); + for (const ev of events) renderEvent(ev); + scrollToBottom(); + } + + // ── Send ───────────────────────────────────────────────────────────────── + + async function sendMessage() { + const text = inputEl.value.trim(); + if (!text || !roomId) return; + inputEl.value = ''; + + const txnId = Date.now() + '_' + (txnCounter++); + try { + await matrixFetch( + 'PUT', + '/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + + '/send/m.room.message/' + txnId, + { msgtype: 'm.text', body: text }, + ); + } catch (e) { + setStatus('Send failed: ' + e.message, true); + } + } + + // ── Sync loop ──────────────────────────────────────────────────────────── + + // Minimal filter: only timeline events from our room, no state/presence/account noise + const SYNC_FILTER = JSON.stringify({ + room: { + rooms: [], // filled in after room resolution + timeline: { limit: 20 }, + state: { not_types: ['*'] }, + ephemeral: { not_types: ['*'] }, + }, + presence: { not_types: ['*'] }, + account_data: { not_types: ['*'] }, + }); + + async function syncLoop() { + syncActive = true; + + // Build a filter scoped to our single room + const filter = JSON.stringify({ + room: { + rooms: [roomId], + timeline: { limit: 20 }, + state: { not_types: ['*'] }, + ephemeral: { not_types: ['*'] }, + }, + presence: { not_types: ['*'] }, + account_data: { not_types: ['*'] }, + }); + + while (syncActive) { + try { + const params = { filter, timeout: '30000' }; + if (syncToken) params.since = syncToken; + + const data = await matrixFetch('GET', '/_matrix/client/v3/sync', null, params); + if (!syncActive) break; + + syncToken = data.next_batch; + + const roomTimeline = + data.rooms && + data.rooms.join && + data.rooms.join[roomId] && + data.rooms.join[roomId].timeline; + + if (roomTimeline && roomTimeline.events && roomTimeline.events.length) { + const atBottom = isScrolledToBottom(); + for (const ev of roomTimeline.events) renderEvent(ev); + if (atBottom) scrollToBottom(); + } + } catch (e) { + if (!syncActive) break; + // Back off before retrying to avoid hammering on persistent errors + await new Promise(r => setTimeout(r, 5000)); + } + } + } + + // ── Entry point ────────────────────────────────────────────────────────── + + async function init() { + if (!SERVER) { setStatus('MATRIX_SERVER_URL is not configured.', true); return; } + if (!ROOM_ALIAS) { setStatus('MATRIX_ROOM_ALIAS is not configured.', true); return; } + + // Handle SSO callback: Matrix redirects back here with ?loginToken= + const urlParams = new URLSearchParams(window.location.search); + const loginToken = urlParams.get('loginToken'); + + if (loginToken) { + try { + await exchangeLoginToken(loginToken); + } catch (e) { + setStatus('Login failed: ' + e.message, true); + return; + } + } else if (!loadCreds()) { + beginSSO(); + return; + } + + try { + setStatus('Connecting…'); + await resolveRoom(); + await joinRoom(); + await loadHistory(); + clearStatus(); + } catch (e) { + setStatus('Connection error: ' + e.message, true); + return; + } + + syncLoop(); + } + + // ── Event listeners ────────────────────────────────────────────────────── + + sendBtn.addEventListener('click', sendMessage); + inputEl.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + window.addEventListener('beforeunload', function () { + syncActive = false; + }); + + init(); +})(); diff --git a/matrix_iframe/templates/matrix_iframe/chat.html b/matrix_iframe/templates/matrix_iframe/chat.html new file mode 100644 index 0000000..7e0658f --- /dev/null +++ b/matrix_iframe/templates/matrix_iframe/chat.html @@ -0,0 +1,19 @@ +{% extends base_template %} + +{% block header %} + +{% endblock %} + +{% block body %} +
+
+
+
+ + +
+
+ +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c76ff50 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "matrix-iframe" +version = "0.1.0" +description = "Embeddable Matrix room chat widget as a Flask blueprint" +requires-python = ">=3.9" +dependencies = ["flask>=2.0"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["matrix_iframe*"] + +[tool.setuptools.package-data] +matrix_iframe = ["templates/**/*", "static/**/*"]