chat drin
This commit is contained in:
1 parent
ef238f3b9c
commit
988a5410f7
12 files changed
+1115
-4
No files matched your search
Submodule gitea-issue-client deleted from 90353c93ad.
+6
-1
@@ -1174,6 +1174,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
|
||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
|
||||
</button>
|
||||
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="sync now">
|
||||
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">sync</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
@@ -1271,7 +1274,6 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="help-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-question text-sm"></i><span class="font-pixel text-[10px]">help</span></button>
|
||||
<span class="font-pixel text-[10px] text-t-dim whitespace-nowrap">© <?= date('Y') ?> hyprcrd</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1463,6 +1465,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="pt-4 border-t border-t-border text-center">
|
||||
<span class="font-pixel text-[10px] text-t-dim">© <?= date('Y') ?> hyprcrd · v<?= APP_VERSION ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,8 +117,13 @@
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
},
|
||||
// CSRF: core calls setCsrf() after auth so plugins can read it
|
||||
setCsrf(token) { csrfToken = String(token || ''); },
|
||||
// CSRF: core calls setCsrf() after auth so plugins can read it.
|
||||
// Emits 'csrf.ready' so plugins that need to make state-changing
|
||||
// requests on init can defer until auth has happened.
|
||||
setCsrf(token) {
|
||||
csrfToken = String(token || '');
|
||||
if (csrfToken) hooks.emit('csrf.ready', csrfToken);
|
||||
},
|
||||
csrf() { return csrfToken; },
|
||||
};
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ $('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||
export async function triggerSync() {
|
||||
const btn = $('cal-sync');
|
||||
if (btn) btn.style.opacity = '0.5';
|
||||
const headerIcon = $('header-sync-icon');
|
||||
if (headerIcon) headerIcon.classList.add('card-spinner');
|
||||
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||
|
||||
const res = await fetch('api/caldav/sync', {
|
||||
@@ -184,6 +186,7 @@ export async function triggerSync() {
|
||||
if (btn) btn.style.opacity = '';
|
||||
|
||||
// Also poll relay
|
||||
// (header spinner stays on until the relay poll completes too — see end)
|
||||
const relayRes = await fetch('api/relay/poll', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
@@ -202,6 +205,7 @@ export async function triggerSync() {
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
||||
}
|
||||
|
||||
function showLog(log) {
|
||||
@@ -283,6 +287,7 @@ export async function loadRelaySettings() {
|
||||
|
||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||
$('header-sync')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
// Auto-sync throttle
|
||||
let lastAutoSync = 0;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Chat Plugin: backend.
|
||||
*
|
||||
* Local message cache + thin proxy over the relays /api/chat routes.
|
||||
* The plugin keeps a local SQLite table so the UI can render conversation
|
||||
* history without re-polling on every open. Reuses the existing relay_url /
|
||||
* relay_username / relay_key settings (no new credentials).
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/chat/conversations → peer list with last msg + unread
|
||||
* GET /api/chat/messages?peer=X → all messages with one peer (asc)
|
||||
* POST /api/chat/send {peer,body} → encrypts + pushes via relay, stores local
|
||||
* POST /api/chat/poll → pulls new from relay, returns count
|
||||
* POST /api/chat/read {peer} → mark all incoming from peer as read
|
||||
* GET /api/chat/recipients → proxy of relays /api/tenants
|
||||
*/
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
body TEXT NOT NULL,
|
||||
relay_id INTEGER,
|
||||
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_read INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
||||
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
");
|
||||
})();
|
||||
|
||||
function chatJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function chatRelaySettings(): array {
|
||||
$db = Database::connect();
|
||||
return [
|
||||
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
|
||||
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
|
||||
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
|
||||
function chatEncrypt(string $plain, string $relayKey): string {
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
|
||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||
$raw = base64_decode($b64);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return $result !== false ? $result : '';
|
||||
}
|
||||
|
||||
function chatGetState(string $key, $default = null) {
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT value FROM chat_state WHERE key = :k');
|
||||
$stmt->execute(['k' => $key]);
|
||||
$v = $stmt->fetchColumn();
|
||||
return $v === false ? $default : $v;
|
||||
}
|
||||
|
||||
function chatSetState(string $key, $value): void {
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT OR REPLACE INTO chat_state (key, value) VALUES (:k, :v)')
|
||||
->execute(['k' => $key, 'v' => (string)$value]);
|
||||
}
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
if ($route === '/api/chat/conversations' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
// Latest message per peer + unread count, single query
|
||||
$rows = $db->query("
|
||||
SELECT
|
||||
peer,
|
||||
MAX(sent_at) AS last_at,
|
||||
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_direction,
|
||||
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
|
||||
FROM chat_messages c1
|
||||
GROUP BY peer
|
||||
ORDER BY last_at DESC
|
||||
")->fetchAll();
|
||||
$totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows));
|
||||
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/messages' && $method === 'GET') {
|
||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
||||
$stmt->execute(['p' => $peer]);
|
||||
chatJson(['messages' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/send' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
$body = (string)($input['body'] ?? '');
|
||||
if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400);
|
||||
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
|
||||
}
|
||||
|
||||
$envelope = chatEncrypt($body, $r['key']);
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200) {
|
||||
$relayErr = '';
|
||||
$decoded = json_decode($response, true);
|
||||
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||
chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]);
|
||||
}
|
||||
|
||||
$resData = json_decode($response, true);
|
||||
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||
|
||||
// Store the outgoing message locally so the UI can render it immediately
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "out", :b, NULL, datetime("now"), 1)')
|
||||
->execute(['p' => $peer, 'b' => $body]);
|
||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/poll' && $method === 'POST') {
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['ok' => true, 'new' => 0, 'note' => 'relay not configured']);
|
||||
}
|
||||
$since = (int)chatGetState('last_relay_id', 0);
|
||||
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat?since=' . $since);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200) {
|
||||
$relayErr = '';
|
||||
$decoded = json_decode($response, true);
|
||||
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||
chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]);
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
$messages = $data['messages'] ?? [];
|
||||
$maxId = (int)($data['max_id'] ?? $since);
|
||||
|
||||
$db = Database::connect();
|
||||
$newCount = 0;
|
||||
$insert = $db->prepare('INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "in", :b, :rid, :ts, 0)');
|
||||
foreach ($messages as $m) {
|
||||
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||
if ($plain === '') continue;
|
||||
$insert->execute([
|
||||
'p' => $m['sender_username'] ?? '?',
|
||||
'b' => $plain,
|
||||
'rid' => (int)$m['id'],
|
||||
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
]);
|
||||
if ($insert->rowCount() > 0) $newCount++;
|
||||
}
|
||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||
|
||||
chatJson(['ok' => true, 'new' => $newCount]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
||||
->execute(['p' => $peer]);
|
||||
chatJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/recipients' && $method === 'GET') {
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['ok' => false, 'error' => 'relay not configured']);
|
||||
}
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/tenants');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($status !== 200) chatJson(['ok' => false, 'error' => "relay returned HTTP $status"]);
|
||||
$data = json_decode($response, true) ?: [];
|
||||
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/* ── Chat plugin styles ── */
|
||||
|
||||
#chat-flyout {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
right: 16px;
|
||||
width: 340px;
|
||||
height: 460px;
|
||||
max-height: calc(100vh - 100px);
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#chat-flyout.hidden { display: none; }
|
||||
|
||||
#chat-flyout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-view.hidden { display: none; }
|
||||
|
||||
/* ── Conversation list ── */
|
||||
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-list-row:hover {
|
||||
background: var(--t-raised);
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-raised);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--t-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-peer {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
color: var(--t-bright);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-time {
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-preview {
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-preview.outgoing::before {
|
||||
content: "› ";
|
||||
color: var(--t-dim);
|
||||
}
|
||||
|
||||
.chat-list-row .chat-unread-badge {
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── Thread view ── */
|
||||
|
||||
#chat-thread-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-thread-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 80%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--t-border);
|
||||
}
|
||||
|
||||
.chat-bubble.in {
|
||||
align-self: flex-start;
|
||||
background: var(--t-raised);
|
||||
color: var(--t-bright);
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.chat-bubble.out {
|
||||
align-self: flex-end;
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
border-color: var(--t-accent);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.chat-bubble-time {
|
||||
font-size: 8px;
|
||||
color: var(--t-dim);
|
||||
margin-top: 2px;
|
||||
font-family: var(--t-font-pixel);
|
||||
}
|
||||
|
||||
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
||||
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
||||
|
||||
#chat-thread-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#chat-thread-input {
|
||||
flex: 1;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-bright);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#chat-thread-input:focus {
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-thread-send {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
#chat-thread-send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Pick recipient view ── */
|
||||
|
||||
#chat-pick-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-pick-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
color: var(--t-bright);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-pick-row:hover {
|
||||
background: var(--t-raised);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Footer button ── */
|
||||
|
||||
#chat-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#chat-footer-btn:hover {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-footer-btn .chat-footer-label {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#chat-footer-btn.has-unread {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-footer-btn .chat-unread-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-footer-btn.has-unread .chat-unread-dot {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes chatBlink {
|
||||
0%, 100% {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
box-shadow: 0 0 0 0 var(--t-accent);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-bright);
|
||||
box-shadow: 0 0 8px 1px var(--t-accent);
|
||||
}
|
||||
}
|
||||
|
||||
#chat-footer-btn.blinking {
|
||||
animation: chatBlink 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- Chat plugin: bottom-right flyout. Hidden by default; toggled by the
|
||||
footer button injected via chat.js. -->
|
||||
<div id="chat-flyout" class="hidden">
|
||||
<div id="chat-flyout-header">
|
||||
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span>
|
||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" title="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
||||
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" title="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
||||
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation list (default view) -->
|
||||
<div id="chat-list-view" class="chat-view">
|
||||
<div id="chat-list" class="chat-list"></div>
|
||||
<div id="chat-empty" class="hidden chat-empty">
|
||||
<i class="ph ph-chats text-2xl text-t-dim mb-2"></i>
|
||||
<p class="font-pixel text-[10px] text-t-dim">no conversations yet</p>
|
||||
<button id="chat-empty-start" class="mt-3 px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">start chat</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation thread view -->
|
||||
<div id="chat-thread-view" class="chat-view hidden">
|
||||
<div id="chat-thread-header">
|
||||
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<i class="ph ph-user-circle text-t-accent text-sm"></i>
|
||||
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
||||
</div>
|
||||
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
||||
<form id="chat-thread-form">
|
||||
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
|
||||
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" title="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New-conversation peer picker -->
|
||||
<div id="chat-pick-view" class="chat-view hidden">
|
||||
<div id="chat-pick-header">
|
||||
<button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<span class="font-pixel text-[10px] text-t-bright flex-1">pick a recipient</span>
|
||||
</div>
|
||||
<div id="chat-pick-list" class="chat-pick-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,374 @@
|
||||
// ── Chat Plugin Frontend ──
|
||||
//
|
||||
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
||||
// (faster while open, slower while closed). Blinks the footer button
|
||||
// when new incoming messages arrive while the flyout is closed.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
let conversations = [];
|
||||
let currentPeer = null;
|
||||
let currentMessages = [];
|
||||
let pollHandle = null;
|
||||
let pollIntervalMs = 20000; // closed default
|
||||
let recipientCache = null; // { tenants: [...], self: '...' }
|
||||
let initialPollDone = false;
|
||||
|
||||
// Chat polls aggressively because the endpoint is cheap (single indexed
|
||||
// SELECT with LIMIT 500). Card sync stays on its own slower interval —
|
||||
// CalDAV is the expensive one. Tab-hidden pauses the timer entirely.
|
||||
const POLL_OPEN_MS = 8000;
|
||||
const POLL_CLOSED_MS = 20000;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatChatTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const diffDays = (now - d) / 86400000;
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !$('chat-flyout')?.classList.contains('hidden');
|
||||
}
|
||||
|
||||
// ── Footer button ─────────────────────────────────────────────────
|
||||
|
||||
function injectFooterButton() {
|
||||
const footer = document.querySelector('footer');
|
||||
if (!footer) return;
|
||||
if (document.getElementById('chat-footer-btn')) return;
|
||||
|
||||
// Append next to the help button on the right side of the footer
|
||||
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
|
||||
if (!rightGroup) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'chat-footer-btn';
|
||||
btn.title = 'chat';
|
||||
btn.innerHTML = `
|
||||
<i class="ph ph-chats-circle text-sm"></i>
|
||||
<span class="chat-footer-label">chat</span>
|
||||
<span id="chat-unread-count" class="chat-unread-dot"></span>
|
||||
`;
|
||||
btn.addEventListener('click', toggleFlyout);
|
||||
rightGroup.appendChild(btn);
|
||||
}
|
||||
|
||||
function setUnreadBadge(n) {
|
||||
const btn = $('chat-footer-btn');
|
||||
if (!btn) return;
|
||||
if (n > 0) {
|
||||
btn.classList.add('has-unread');
|
||||
} else {
|
||||
btn.classList.remove('has-unread');
|
||||
btn.classList.remove('blinking');
|
||||
}
|
||||
}
|
||||
|
||||
function startBlink() {
|
||||
if (isOpen()) return;
|
||||
$('chat-footer-btn')?.classList.add('blinking');
|
||||
}
|
||||
|
||||
function stopBlink() {
|
||||
$('chat-footer-btn')?.classList.remove('blinking');
|
||||
}
|
||||
|
||||
// ── Flyout open / close ───────────────────────────────────────────
|
||||
|
||||
async function toggleFlyout() {
|
||||
if (isOpen()) closeFlyout();
|
||||
else await openFlyout();
|
||||
}
|
||||
|
||||
async function openFlyout() {
|
||||
$('chat-flyout').classList.remove('hidden');
|
||||
stopBlink();
|
||||
showListView();
|
||||
pollIntervalMs = POLL_OPEN_MS;
|
||||
restartPoll();
|
||||
await loadConversations();
|
||||
await pollOnce(); // immediate refresh on open
|
||||
await loadConversations();
|
||||
}
|
||||
|
||||
function closeFlyout() {
|
||||
$('chat-flyout').classList.add('hidden');
|
||||
pollIntervalMs = POLL_CLOSED_MS;
|
||||
restartPoll();
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────
|
||||
|
||||
function showListView() {
|
||||
$('chat-list-view').classList.remove('hidden');
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
currentPeer = null;
|
||||
}
|
||||
|
||||
function showThreadView(peer) {
|
||||
$('chat-list-view').classList.add('hidden');
|
||||
$('chat-thread-view').classList.remove('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-thread-peer').textContent = peer;
|
||||
currentPeer = peer;
|
||||
}
|
||||
|
||||
function showPickView() {
|
||||
$('chat-list-view').classList.add('hidden');
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Conversation list ─────────────────────────────────────────────
|
||||
|
||||
async function loadConversations() {
|
||||
const res = await fetch('api/chat/conversations').then(r => r.json()).catch(() => null);
|
||||
if (!res) return;
|
||||
conversations = res.conversations || [];
|
||||
setUnreadBadge(res.total_unread || 0);
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
function renderConversations() {
|
||||
const list = $('chat-list');
|
||||
const empty = $('chat-empty');
|
||||
if (!list || !empty) return;
|
||||
if (!conversations.length) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
list.innerHTML = conversations.map(c => {
|
||||
const initial = (c.peer || '?').charAt(0).toUpperCase();
|
||||
const unreadBadge = parseInt(c.unread) > 0
|
||||
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
||||
const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview';
|
||||
return `
|
||||
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}">
|
||||
<div class="chat-list-avatar">${escapeHtml(initial)}</div>
|
||||
<div class="chat-list-info">
|
||||
<div class="chat-list-peer">
|
||||
<span>${escapeHtml(c.peer)}</span>
|
||||
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
||||
</div>
|
||||
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div>
|
||||
</div>
|
||||
${unreadBadge}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.chat-list-row').forEach(row => {
|
||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Thread ────────────────────────────────────────────────────────
|
||||
|
||||
async function openConversation(peer) {
|
||||
showThreadView(peer);
|
||||
await loadThread(peer);
|
||||
// Mark all incoming as read
|
||||
await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {});
|
||||
// Refresh badges in the background
|
||||
loadConversations();
|
||||
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
||||
}
|
||||
|
||||
async function loadThread(peer) {
|
||||
const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer))
|
||||
.then(r => r.json()).catch(() => null);
|
||||
if (!res) return;
|
||||
currentMessages = res.messages || [];
|
||||
renderThread();
|
||||
}
|
||||
|
||||
function renderThread() {
|
||||
const wrap = $('chat-thread-messages');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = currentMessages.map(m => {
|
||||
const cls = m.direction === 'out' ? 'out' : 'in';
|
||||
return `
|
||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}</div>
|
||||
`;
|
||||
}).join('');
|
||||
// Scroll to bottom
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendMessage(e) {
|
||||
e?.preventDefault();
|
||||
const input = $('chat-thread-input');
|
||||
const body = input.value.trim();
|
||||
if (!body || !currentPeer) return;
|
||||
const sendBtn = $('chat-thread-send');
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await hyprcrd.api('api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { peer: currentPeer, body },
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert('Send failed: ' + (res.error || 'unknown'));
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
await loadThread(currentPeer);
|
||||
await loadConversations();
|
||||
} catch (err) {
|
||||
alert('Send error: ' + err.message);
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pick recipient ────────────────────────────────────────────────
|
||||
|
||||
async function openPickView() {
|
||||
showPickView();
|
||||
const list = $('chat-pick-list');
|
||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||
|
||||
if (!recipientCache) {
|
||||
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
||||
if (!res?.ok) {
|
||||
list.innerHTML = `<div class="px-3 py-3 text-[10px] text-t-danger font-pixel">${escapeHtml(res?.error || 'failed to load recipients')}</div>`;
|
||||
return;
|
||||
}
|
||||
recipientCache = res;
|
||||
}
|
||||
|
||||
const tenants = recipientCache.tenants || [];
|
||||
const self = recipientCache.self;
|
||||
const others = tenants.filter(t => t.username !== self);
|
||||
if (!others.length) {
|
||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">no other users on the relay</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = others.map(t => `
|
||||
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}">
|
||||
<i class="ph ph-user-circle text-sm"></i>
|
||||
<span>${escapeHtml(t.username)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────
|
||||
|
||||
async function pollOnce() {
|
||||
const icon = $('chat-refresh-icon');
|
||||
if (icon && isOpen()) icon.classList.add('card-spinner');
|
||||
try {
|
||||
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
||||
if (!res?.ok) return;
|
||||
const newCount = res.new || 0;
|
||||
if (newCount > 0) {
|
||||
await loadConversations();
|
||||
// If we're inside a thread for any peer, refresh + auto-read it
|
||||
if (isOpen() && currentPeer) {
|
||||
await loadThread(currentPeer);
|
||||
hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } })
|
||||
.then(() => loadConversations()).catch(() => {});
|
||||
}
|
||||
// Blink if the flyout is closed
|
||||
if (!isOpen()) startBlink();
|
||||
} else if (initialPollDone === false) {
|
||||
// First poll: refresh badges in case we have unread from a previous session
|
||||
await loadConversations();
|
||||
}
|
||||
initialPollDone = true;
|
||||
} finally {
|
||||
if (icon) icon.classList.remove('card-spinner');
|
||||
}
|
||||
}
|
||||
|
||||
function restartPoll() {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
pollHandle = setInterval(pollOnce, pollIntervalMs);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
if (!$('chat-flyout')) return;
|
||||
|
||||
injectFooterButton();
|
||||
|
||||
$('chat-close-btn').addEventListener('click', closeFlyout);
|
||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||
$('chat-new-btn').addEventListener('click', openPickView);
|
||||
$('chat-empty-start')?.addEventListener('click', openPickView);
|
||||
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
||||
$('chat-pick-back').addEventListener('click', showListView);
|
||||
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||
|
||||
// Esc closes flyout when focus is inside it
|
||||
$('chat-flyout').addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
||||
});
|
||||
|
||||
// Defer the first POST (poll) until auth has set csrf. GETs are fine immediately.
|
||||
loadConversations().catch(() => {});
|
||||
const startPolling = () => {
|
||||
pollOnce().catch(() => {});
|
||||
restartPoll();
|
||||
};
|
||||
if (hyprcrd.util.csrf?.()) {
|
||||
startPolling();
|
||||
} else {
|
||||
hyprcrd.hooks.on('csrf.ready', startPolling);
|
||||
}
|
||||
|
||||
// Pause polling when the page is hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||
} else {
|
||||
pollOnce().catch(() => {});
|
||||
restartPoll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'chat',
|
||||
version: '1.0.0',
|
||||
description: 'Encrypted chat between relay tenants',
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "chat",
|
||||
"version": "1.0.0",
|
||||
"description": "Encrypted chat between relay tenants. Footer flyout that blinks on new messages.",
|
||||
"frontend": {
|
||||
"js": ["chat.js"],
|
||||
"css": ["chat.css"],
|
||||
"html": ["chat.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -164,6 +164,23 @@ $db->exec('CREATE TABLE IF NOT EXISTS attachments (
|
||||
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)');
|
||||
|
||||
// Chat: separate from `messages` so chat traffic doesnt land in card inboxes.
|
||||
// Same decrypt-reencrypt hop as messages: arrives encrypted with senders
|
||||
// secret, stored at-rest under the inter-tenant key, returned re-encrypted
|
||||
// with the recipients secret.
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipient_id INTEGER NOT NULL,
|
||||
sender_id INTEGER,
|
||||
sender_username TEXT,
|
||||
body TEXT NOT NULL,
|
||||
fetched INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
|
||||
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
@@ -181,6 +198,8 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
// Cleanup: 24h ephemeral storage, expired sessions, old rate limits
|
||||
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
|
||||
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
||||
// Chat: 7 days, longer than card sync but still ephemeral
|
||||
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
||||
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
// Chat routes (client API only):
|
||||
// POST /api/chat — send a chat message to {recipient}
|
||||
// GET /api/chat?since=N — pull all chat messages addressed to me with id > N
|
||||
//
|
||||
// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the
|
||||
// senders client secret, stored at-rest under the inter-tenant key, returned
|
||||
// re-encrypted with the recipients client secret. Relay sees plaintext only
|
||||
// during the routing hop.
|
||||
|
||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
// $clientTenant is set by messages.php earlier in the chain via authClient().
|
||||
// We re-resolve here to keep this file self-contained.
|
||||
$clientTenant = $clientTenant ?? authClient($db);
|
||||
|
||||
if ($route === '/api/chat' && $method === 'POST') {
|
||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$encryptedBody = (string)($input['body'] ?? '');
|
||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||
|
||||
$recipient = getTenantByUsername($db, $recipientName);
|
||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||
|
||||
if (empty($clientTenant['secret_encrypted'])) {
|
||||
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||
}
|
||||
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500);
|
||||
|
||||
$plain = clientDecrypt($encryptedBody, $senderSecret);
|
||||
if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400);
|
||||
$stored = clientEncrypt($plain, $interKey);
|
||||
|
||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
||||
->execute([
|
||||
'r' => $recipient['id'],
|
||||
's' => $clientTenant['id'],
|
||||
'sn' => $clientTenant['username'],
|
||||
'b' => $stored,
|
||||
]);
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat' && $method === 'GET') {
|
||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
if (empty($clientTenant['secret_encrypted'])) {
|
||||
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||
}
|
||||
|
||||
$since = (int)($_GET['since'] ?? 0);
|
||||
$stmt = $db->prepare('SELECT id, sender_username, body, created_at FROM chat_messages WHERE recipient_id = :r AND id > :s ORDER BY id ASC LIMIT 500');
|
||||
$stmt->execute(['r' => $clientTenant['id'], 's' => $since]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||
|
||||
$out = [];
|
||||
$maxId = $since;
|
||||
foreach ($rows as $row) {
|
||||
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
||||
$row['body'] = clientEncrypt($plain, $recipientSecret);
|
||||
$out[] = $row;
|
||||
if ((int)$row['id'] > $maxId) $maxId = (int)$row['id'];
|
||||
}
|
||||
// Mark fetched so cleanup can prune them sooner if desired. We still
|
||||
// keep them around for the 7-day window so a re-poll with since=0
|
||||
// backfills history if a client wipes its local cache.
|
||||
if ($out) {
|
||||
$db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
|
||||
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
||||
}
|
||||
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
require __DIR__ . '/inc/routes/account.php';
|
||||
require __DIR__ . '/inc/routes/admin.php';
|
||||
require __DIR__ . '/inc/routes/messages.php';
|
||||
require __DIR__ . '/inc/routes/chat.php';
|
||||
|
||||
jsonResponse(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user