From 52da9abd447443b5ea938d2d62e6609109a20e0f Mon Sep 17 00:00:00 2001
From: th
Date: Wed, 15 Apr 2026 20:15:38 +0200
Subject: [PATCH] chat: client-side rooms, disposable flag, history backfill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extends the local chat cache to mirror rooms + membership from the
relay. chat_messages grows a room_uid column; peer is reused as
author-username for group rows so a single table covers both kinds.
New local tables:
chat_rooms — uid, name, owner, synced_at
chat_room_members — room_uid + username
chat_thread_settings — per-peer disposable flag
New / extended endpoints:
GET/POST/PATCH/DELETE /api/chat/rooms[/{uid}] — proxy to relay
POST /api/chat/backfill — pulls older messages from relay
GET/POST /api/chat/thread-settings — disposable toggle
/api/chat/conversations — mixed list of peers + rooms
/api/chat/messages — accepts peer OR room_uid
/api/chat/send — accepts peer OR room_uid; forwards disposable
/api/chat/poll — mirrors rooms on every tick; sets direction
from `mine` flag so multi-device group sends
render as out-bubbles
Co-Authored-By: Claude Opus 4.6 (1M context)
---
public/plugins/chat/backend.php | 398 ++++++++++++++++++++++++++++++--
1 file changed, 375 insertions(+), 23 deletions(-)
diff --git a/public/plugins/chat/backend.php b/public/plugins/chat/backend.php
index 49ced16..9965465 100644
--- a/public/plugins/chat/backend.php
+++ b/public/plugins/chat/backend.php
@@ -43,6 +43,46 @@
if (!in_array('seen', $colNames)) {
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
}
+ // Group chat: `room_uid` set → message belongs to a room (group
+ // thread), and `peer` holds the author's username (always — 1:1
+ // messages use peer for the other party, group messages reuse the
+ // column for the sender). NULL room_uid = classic 1:1.
+ if (!in_array('room_uid', $colNames)) {
+ $db->exec("ALTER TABLE chat_messages ADD COLUMN room_uid TEXT");
+ $db->exec("CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)");
+ }
+
+ // Chat rooms + membership, mirrored from the relay on poll. The
+ // client is the read-through cache; relay remains the source of
+ // truth so a second device can join a room just by polling.
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS chat_rooms (
+ uid TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ owner_username TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS chat_room_members (
+ room_uid TEXT NOT NULL,
+ username TEXT NOT NULL,
+ PRIMARY KEY (room_uid, username)
+ )
+ ");
+
+ // Per-thread settings. Currently: `disposable` flag for 1:1 threads
+ // — when true, the relay drops the messages after the 7-day window
+ // instead of keeping them in the long-term store. Rooms are always
+ // persistent (enforced relay-side), so this only applies to peers.
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS chat_thread_settings (
+ peer TEXT PRIMARY KEY,
+ disposable INTEGER NOT NULL DEFAULT 0
+ )
+ ");
+
// 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 (
@@ -106,49 +146,102 @@ 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("
+ // 1:1 threads — one row per peer (room_uid IS NULL).
+ $oneOnOne = $db->query("
SELECT
- peer,
+ 'peer' AS kind,
+ peer AS key,
+ peer AS label,
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,
+ (SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer AND c2.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_body,
+ (SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer AND c3.room_uid IS NULL 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
+ WHERE room_uid IS NULL
GROUP BY peer
- ORDER BY last_at DESC
")->fetchAll();
- $totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows));
+
+ // Group rooms — join rooms to their newest message (if any); rooms
+ // with no messages yet still render so the user can post there.
+ $rooms = $db->query("
+ SELECT
+ 'room' AS kind,
+ r.uid AS key,
+ r.name AS label,
+ r.owner_username AS owner,
+ (SELECT MAX(sent_at) FROM chat_messages WHERE room_uid = r.uid) AS last_at,
+ (SELECT body FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_body,
+ (SELECT peer FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_author,
+ (SELECT COUNT(*) FROM chat_messages WHERE room_uid = r.uid AND direction = 'in' AND is_read = 0) AS unread
+ FROM chat_rooms r
+ ")->fetchAll();
+
+ $rows = array_merge($oneOnOne, $rooms);
+ // Sort newest-first, nulls (empty rooms) to the bottom.
+ usort($rows, function ($a, $b) {
+ $al = $a['last_at'] ?? ''; $bl = $b['last_at'] ?? '';
+ if ($al === $bl) return 0;
+ if ($al === '') return 1;
+ if ($bl === '') return -1;
+ return strcmp($bl, $al);
+ });
+ $totalUnread = array_sum(array_map(fn($r) => (int)($r['unread'] ?? 0), $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);
+ $roomUid = trim((string)($_GET['room_uid'] ?? ''));
+ if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$db = Database::connect();
- $stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
- $stmt->execute(['p' => $peer]);
+ if ($roomUid !== '') {
+ // Group thread: return all messages for the room. `peer` is the
+ // author username; UI uses author === self to decide bubble
+ // alignment instead of the direction flag (direction is set
+ // from THIS device's perspective, so a message sent from
+ // another device comes in as direction='in' while peer==self).
+ $stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen, room_uid FROM chat_messages WHERE room_uid = :r ORDER BY id ASC LIMIT 500');
+ $stmt->execute(['r' => $roomUid]);
+ } else {
+ $stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p AND room_uid IS NULL 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'] ?? ''));
+ $roomUid = trim((string)($input['room_uid'] ?? ''));
$body = (string)($input['body'] ?? '');
- if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400);
+ if ($body === '') chatJson(['error' => 'body required'], 400);
+ if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) {
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
}
+ $db = Database::connect();
+ // Look up per-thread disposable flag (1:1 only; rooms are always
+ // persistent on the relay).
+ $disposable = 0;
+ if ($peer !== '') {
+ $stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
+ $stmt->execute(['p' => $peer]);
+ $disposable = (int)($stmt->fetchColumn() ?: 0);
+ }
+
$envelope = chatEncrypt($body, $r['key']);
+ $payload = $roomUid !== ''
+ ? ['room_uid' => $roomUid, 'body' => $envelope]
+ : ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable];
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
$ch = curl_init($relayChatUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]),
+ CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
@@ -176,11 +269,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
// Store the outgoing message locally so the UI can render it
- // immediately. relay_id is the relay's autoincrement id; the
- // receipt-poll matches against it to update delivered/seen state.
- $db = Database::connect();
- $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)')
- ->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]);
+ // immediately. For group messages `peer` holds the author
+ // (= self); for 1:1 it holds the other party as before.
+ $localPeer = $roomUid !== '' ? $r['user'] : $peer;
+ $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen, room_uid) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0, :ru)')
+ ->execute([
+ 'p' => $localPeer,
+ 'b' => $body,
+ 'rid' => $relayId,
+ 'ru' => $roomUid !== '' ? $roomUid : null,
+ ]);
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
@@ -217,20 +315,78 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$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)');
+ // `mine` on group messages (== came back from the relay because
+ // another device of ours sent it) gets direction='out' so the UI
+ // renders it aligned right; relay_id uniqueness dedupes against
+ // the local insert we did on send.
+ $insert = $db->prepare('
+ INSERT OR IGNORE INTO chat_messages
+ (peer, direction, body, relay_id, sent_at, is_read, room_uid)
+ VALUES (:p, :dir, :b, :rid, :ts, 0, :ru)
+ ');
foreach ($messages as $m) {
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
if ($plain === '') continue;
+ $isGroup = !empty($m['room_uid']);
+ $mine = !empty($m['mine']);
$insert->execute([
- 'p' => $m['sender_username'] ?? '?',
- 'b' => $plain,
+ 'p' => $m['sender_username'] ?? '?',
+ 'dir' => $mine ? 'out' : 'in',
+ 'b' => $plain,
'rid' => (int)$m['id'],
- 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
+ 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
+ 'ru' => $isGroup ? $m['room_uid'] : null,
]);
if ($insert->rowCount() > 0) $newCount++;
}
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
+ // Mirror rooms + membership into the local cache on every poll.
+ // Cheap (one extra GET), and it lets a second device see a room
+ // it was just added to without a separate fetch.
+ $ch3 = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
+ curl_setopt_array($ch3, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
+ CURLOPT_TIMEOUT => 10,
+ ]);
+ $resp3 = curl_exec($ch3);
+ $st3 = curl_getinfo($ch3, CURLINFO_HTTP_CODE);
+ curl_close($ch3);
+ if ($st3 === 200) {
+ $roomsData = json_decode($resp3, true) ?: [];
+ $rooms = $roomsData['rooms'] ?? [];
+ $remoteUids = [];
+ $upsert = $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, :c, datetime("now"))');
+ $delMem = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u');
+ $insMem = $db->prepare('INSERT OR IGNORE INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
+ foreach ($rooms as $room) {
+ $uid = (string)($room['uid'] ?? '');
+ if ($uid === '') continue;
+ $remoteUids[] = $uid;
+ $upsert->execute([
+ 'u' => $uid,
+ 'n' => (string)($room['name'] ?? $uid),
+ 'o' => (string)($room['owner_username'] ?? ''),
+ 'c' => (string)($room['created_at'] ?? date('Y-m-d H:i:s')),
+ ]);
+ $delMem->execute(['u' => $uid]);
+ foreach (($room['members'] ?? []) as $member) {
+ $insMem->execute(['u' => $uid, 'un' => (string)$member]);
+ }
+ }
+ // Drop local rooms we were removed from (owner kick / self-leave
+ // on another device). Messages for them stay on disk — harmless,
+ // UI won't list them without a room entry.
+ $existing = $db->query('SELECT uid FROM chat_rooms')->fetchAll(PDO::FETCH_COLUMN);
+ foreach ($existing as $uid) {
+ if (!in_array($uid, $remoteUids, true)) {
+ $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
+ $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
+ }
+ }
+ }
+
// ── Receipt poll for our SENT messages ──────────────────
// Pulls fetched/seen state for everything we've sent. Updates
// local outgoing rows by relay_id. Uses an independent `since`
@@ -288,9 +444,18 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
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);
+ $roomUid = trim((string)($input['room_uid'] ?? ''));
+ if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$db = Database::connect();
- $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
+ if ($roomUid !== '') {
+ $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE room_uid = :r AND direction = "in" AND is_read = 0')
+ ->execute(['r' => $roomUid]);
+ // Group "seen" receipts aren't a thing in v1 — skip the relay
+ // notification. The local is_read flag is enough for badge UI.
+ chatJson(['ok' => true]);
+ }
+
+ $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND room_uid IS NULL AND direction = "in" AND is_read = 0')
->execute(['p' => $peer]);
// Tell the relay this peer's messages have been seen by us so the
@@ -338,4 +503,191 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$data = json_decode($response, true) ?: [];
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
}
+
+ // ── Rooms CRUD (proxy to relay) ──────────────────────────────
+
+ if ($route === '/api/chat/rooms' && $method === 'GET') {
+ $db = Database::connect();
+ $rooms = $db->query('SELECT uid, name, owner_username, created_at FROM chat_rooms ORDER BY created_at DESC')->fetchAll();
+ $mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :u ORDER BY username');
+ foreach ($rooms as &$room) {
+ $mstmt->execute(['u' => $room['uid']]);
+ $room['members'] = array_column($mstmt->fetchAll(), 'username');
+ }
+ chatJson(['ok' => true, 'rooms' => $rooms]);
+ }
+
+ if ($route === '/api/chat/rooms' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $name = trim((string)($input['name'] ?? ''));
+ $members = is_array($input['members'] ?? null) ? $input['members'] : [];
+ if ($name === '') chatJson(['error' => 'name required'], 400);
+ if (count(array_filter($members, fn($m) => trim((string)$m) !== '')) === 0) {
+ chatJson(['error' => 'at least one other member required'], 400);
+ }
+ $r = chatRelaySettings();
+ if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
+
+ // Client-generated UID, same convention as collab buckets.
+ $uid = bin2hex(random_bytes(8));
+ $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode(['uid' => $uid, 'name' => $name, 'members' => $members]),
+ 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) {
+ $err = json_decode($response, true);
+ chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
+ }
+ $data = json_decode($response, true) ?: [];
+ // Mirror locally right away so the next conversations list shows
+ // the room without waiting for a poll.
+ $db = Database::connect();
+ $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, datetime("now"), datetime("now"))')
+ ->execute(['u' => $uid, 'n' => $name, 'o' => $r['user']]);
+ $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
+ $insMem = $db->prepare('INSERT INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
+ foreach (($data['members'] ?? []) as $mu) {
+ $insMem->execute(['u' => $uid, 'un' => $mu]);
+ }
+ chatJson(['ok' => true, 'uid' => $uid, 'members' => $data['members'] ?? []]);
+ }
+
+ if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
+ $uid = $m[1];
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $r = chatRelaySettings();
+ if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
+ $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CUSTOMREQUEST => 'PATCH',
+ CURLOPT_POSTFIELDS => json_encode($input),
+ 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);
+ // The next poll will refresh the local room mirror with the new
+ // name / member list. No need to replay the update here.
+ chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
+ }
+
+ if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
+ $uid = $m[1];
+ $r = chatRelaySettings();
+ if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
+ $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CUSTOMREQUEST => 'DELETE',
+ 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) {
+ $db = Database::connect();
+ $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
+ $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
+ }
+ chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
+ }
+
+ // ── Backfill older messages for a thread (scrollback / cold start) ──
+
+ if ($route === '/api/chat/backfill' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $peer = trim((string)($input['peer'] ?? ''));
+ $roomUid = trim((string)($input['room_uid'] ?? ''));
+ $before = (int)($input['before'] ?? 0);
+ $limit = max(1, min(500, (int)($input['limit'] ?? 100)));
+ if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
+
+ $r = chatRelaySettings();
+ if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
+
+ $qs = http_build_query(array_filter([
+ 'room_uid' => $roomUid ?: null,
+ 'peer' => $peer ?: null,
+ 'before' => $before > 0 ? $before : null,
+ 'limit' => $limit,
+ ], fn($v) => $v !== null));
+ $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/backfill?' . $qs);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
+ CURLOPT_TIMEOUT => 15,
+ ]);
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($status !== 200) {
+ $err = json_decode($response, true);
+ chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
+ }
+ $data = json_decode($response, true) ?: [];
+ $messages = $data['messages'] ?? [];
+
+ $db = Database::connect();
+ $newCount = 0;
+ $insert = $db->prepare('
+ INSERT OR IGNORE INTO chat_messages
+ (peer, direction, body, relay_id, sent_at, is_read, room_uid)
+ VALUES (:p, :dir, :b, :rid, :ts, 1, :ru)
+ ');
+ foreach ($messages as $m) {
+ $plain = chatDecrypt($m['body'] ?? '', $r['key']);
+ if ($plain === '') continue;
+ $insert->execute([
+ 'p' => $m['sender_username'] ?? '?',
+ 'dir' => !empty($m['mine']) ? 'out' : 'in',
+ 'b' => $plain,
+ 'rid' => (int)$m['id'],
+ 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
+ 'ru' => !empty($m['room_uid']) ? $m['room_uid'] : null,
+ ]);
+ if ($insert->rowCount() > 0) $newCount++;
+ }
+ chatJson(['ok' => true, 'backfilled' => $newCount]);
+ }
+
+ // ── Per-thread settings (currently just the disposable flag) ──
+
+ if ($route === '/api/chat/thread-settings' && $method === 'GET') {
+ $peer = trim((string)($_GET['peer'] ?? ''));
+ if ($peer === '') chatJson(['error' => 'peer required'], 400);
+ $db = Database::connect();
+ $stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
+ $stmt->execute(['p' => $peer]);
+ $disposable = (int)($stmt->fetchColumn() ?: 0);
+ chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
+ }
+
+ if ($route === '/api/chat/thread-settings' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $peer = trim((string)($input['peer'] ?? ''));
+ $disposable = !empty($input['disposable']) ? 1 : 0;
+ if ($peer === '') chatJson(['error' => 'peer required'], 400);
+ $db = Database::connect();
+ $db->prepare('INSERT OR REPLACE INTO chat_thread_settings (peer, disposable) VALUES (:p, :d)')
+ ->execute(['p' => $peer, 'd' => $disposable]);
+ chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
+ }
});
|