chat: client-side rooms, disposable flag, history backfill
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
044caae447
commit
52da9abd44
1 file changed
+375
-23
+375
-23
@@ -43,6 +43,46 @@
|
|||||||
if (!in_array('seen', $colNames)) {
|
if (!in_array('seen', $colNames)) {
|
||||||
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
|
$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)
|
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS chat_state (
|
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') {
|
if ($route === '/api/chat/conversations' && $method === 'GET') {
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
// Latest message per peer + unread count, single query
|
// 1:1 threads — one row per peer (room_uid IS NULL).
|
||||||
$rows = $db->query("
|
$oneOnOne = $db->query("
|
||||||
SELECT
|
SELECT
|
||||||
peer,
|
'peer' AS kind,
|
||||||
|
peer AS key,
|
||||||
|
peer AS label,
|
||||||
MAX(sent_at) AS last_at,
|
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 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 ORDER BY id DESC LIMIT 1) AS last_direction,
|
(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
|
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
|
||||||
FROM chat_messages c1
|
FROM chat_messages c1
|
||||||
|
WHERE room_uid IS NULL
|
||||||
GROUP BY peer
|
GROUP BY peer
|
||||||
ORDER BY last_at DESC
|
|
||||||
")->fetchAll();
|
")->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]);
|
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/messages' && $method === 'GET') {
|
if ($route === '/api/chat/messages' && $method === 'GET') {
|
||||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
$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();
|
$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');
|
if ($roomUid !== '') {
|
||||||
$stmt->execute(['p' => $peer]);
|
// 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()]);
|
chatJson(['messages' => $stmt->fetchAll()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/send' && $method === 'POST') {
|
if ($route === '/api/chat/send' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$peer = trim((string)($input['peer'] ?? ''));
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
$body = (string)($input['body'] ?? '');
|
$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();
|
$r = chatRelaySettings();
|
||||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||||
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
|
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']);
|
$envelope = chatEncrypt($body, $r['key']);
|
||||||
|
$payload = $roomUid !== ''
|
||||||
|
? ['room_uid' => $roomUid, 'body' => $envelope]
|
||||||
|
: ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable];
|
||||||
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
|
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
|
||||||
$ch = curl_init($relayChatUrl);
|
$ch = curl_init($relayChatUrl);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]),
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'X-Relay-User: ' . $r['user'],
|
'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;
|
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||||
|
|
||||||
// Store the outgoing message locally so the UI can render it
|
// Store the outgoing message locally so the UI can render it
|
||||||
// immediately. relay_id is the relay's autoincrement id; the
|
// immediately. For group messages `peer` holds the author
|
||||||
// receipt-poll matches against it to update delivered/seen state.
|
// (= self); for 1:1 it holds the other party as before.
|
||||||
$db = Database::connect();
|
$localPeer = $roomUid !== '' ? $r['user'] : $peer;
|
||||||
$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)')
|
$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' => $peer, 'b' => $body, 'rid' => $relayId]);
|
->execute([
|
||||||
|
'p' => $localPeer,
|
||||||
|
'b' => $body,
|
||||||
|
'rid' => $relayId,
|
||||||
|
'ru' => $roomUid !== '' ? $roomUid : null,
|
||||||
|
]);
|
||||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,20 +315,78 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
|
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$newCount = 0;
|
$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) {
|
foreach ($messages as $m) {
|
||||||
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||||
if ($plain === '') continue;
|
if ($plain === '') continue;
|
||||||
|
$isGroup = !empty($m['room_uid']);
|
||||||
|
$mine = !empty($m['mine']);
|
||||||
$insert->execute([
|
$insert->execute([
|
||||||
'p' => $m['sender_username'] ?? '?',
|
'p' => $m['sender_username'] ?? '?',
|
||||||
'b' => $plain,
|
'dir' => $mine ? 'out' : 'in',
|
||||||
|
'b' => $plain,
|
||||||
'rid' => (int)$m['id'],
|
'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 ($insert->rowCount() > 0) $newCount++;
|
||||||
}
|
}
|
||||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
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 ──────────────────
|
// ── Receipt poll for our SENT messages ──────────────────
|
||||||
// Pulls fetched/seen state for everything we've sent. Updates
|
// Pulls fetched/seen state for everything we've sent. Updates
|
||||||
// local outgoing rows by relay_id. Uses an independent `since`
|
// 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') {
|
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$peer = trim((string)($input['peer'] ?? ''));
|
$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 = 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]);
|
->execute(['p' => $peer]);
|
||||||
|
|
||||||
// Tell the relay this peer's messages have been seen by us so the
|
// 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) ?: [];
|
$data = json_decode($response, true) ?: [];
|
||||||
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
|
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]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user