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:
thandClaude Opus 4.6 committed 2026-04-15 20:15:38 +02:00
1 parent 044caae447
commit 52da9abd44
1 file changed
+371 -19
+371 -19
View File
@@ -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');
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'] ?? '?',
'dir' => $mine ? 'out' : 'in',
'b' => $plain,
'rid' => (int)$m['id'],
'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]);
}
});