Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce8fa4211 | ||
|
|
8cd6015cad | ||
|
|
6aa555cc6a | ||
|
|
0fd97e5d71 | ||
|
|
9267c7c6a1 | ||
|
|
52da9abd44 | ||
|
|
044caae447 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.3');
|
define('APP_VERSION', '1.9.5');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.3",
|
"version": "1.9.5",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+383
-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,199 @@ 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));
|
||||||
|
$relayUrl = rtrim($r['url'], '/') . '/api/chat/rooms';
|
||||||
|
$ch = curl_init($relayUrl);
|
||||||
|
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);
|
||||||
|
$detail = $err['error'] ?? "relay HTTP $status";
|
||||||
|
// 404 on this path almost always means the relay is running a
|
||||||
|
// pre-group-chat build. Surface that explicitly so the user
|
||||||
|
// doesn't go hunting for data issues.
|
||||||
|
if ($status === 404) {
|
||||||
|
$detail = "relay does not know /api/chat/rooms — it's likely running old code. Restart the relay after pulling the latest commits.";
|
||||||
|
}
|
||||||
|
chatJson(['ok' => false, 'error' => $detail, 'url' => $relayUrl, 'relay_status' => $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]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -329,6 +329,148 @@
|
|||||||
color: var(--t-accent);
|
color: var(--t-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-pick-row.selected {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.chat-pick-row .chat-pick-check {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--t-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.chat-pick-row:not(.selected) .chat-pick-check { visibility: hidden; }
|
||||||
|
|
||||||
|
/* Picker mode switch (1:1 vs. group). */
|
||||||
|
.chat-pick-mode {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-pick-mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.chat-pick-mode-btn:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.chat-pick-mode-btn.active {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group-name {
|
||||||
|
margin: 8px 10px 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--t-font-mono, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.chat-group-name:focus { border-color: var(--t-accent); }
|
||||||
|
.chat-group-name.hidden { display: none; }
|
||||||
|
|
||||||
|
.chat-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-group-actions.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Room-info view: member list + owner actions. */
|
||||||
|
#chat-room-info-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-room-info-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .section-label {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.chat-room-member-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.chat-room-member-row .owner-badge {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
background: var(--t-raised);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--t-accent);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.chat-room-info-body .btn.danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Author name above group-thread bubbles. Shown only when the author
|
||||||
|
changes, so consecutive bubbles from the same person aren't repeated.
|
||||||
|
Left-aligned so it sits above the left-aligned incoming bubble. */
|
||||||
|
.chat-bubble-author {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-accent2, var(--t-accent));
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0 2px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer-tab variant for rooms — group icon instead of initial. */
|
||||||
|
.chat-peer-tab .chat-peer-tab-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disposable / info icons in flyout header. */
|
||||||
|
#chat-thread-disposable.active {
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Footer button ── */
|
/* ── Footer button ── */
|
||||||
|
|
||||||
#chat-footer-btn {
|
#chat-footer-btn {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
|
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
|
||||||
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
|
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
||||||
|
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
|
||||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="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" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="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>
|
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
||||||
@@ -31,12 +33,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New-conversation peer picker -->
|
<!-- New-conversation peer picker / group creation.
|
||||||
|
Shared view: a mode toggle at top switches between picking one
|
||||||
|
peer (1:1) and building a group (name + multi-select). -->
|
||||||
<div id="chat-pick-view" class="chat-view hidden">
|
<div id="chat-pick-view" class="chat-view hidden">
|
||||||
<div id="chat-pick-header">
|
<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>
|
<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>
|
<span id="chat-pick-title" class="font-pixel text-[10px] text-t-bright flex-1">new conversation</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-pick-mode" class="chat-pick-mode">
|
||||||
|
<button class="chat-pick-mode-btn active" data-mode="peer" type="button">1:1</button>
|
||||||
|
<button class="chat-pick-mode-btn" data-mode="group" type="button">group</button>
|
||||||
|
</div>
|
||||||
|
<input id="chat-group-name" class="chat-group-name hidden" type="text" autocomplete="off" placeholder="group name..." maxlength="80">
|
||||||
<div id="chat-pick-list" class="chat-pick-list"></div>
|
<div id="chat-pick-list" class="chat-pick-list"></div>
|
||||||
|
<div id="chat-group-actions" class="chat-group-actions hidden">
|
||||||
|
<span id="chat-group-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button id="chat-group-create" type="button" class="px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Room info / member management view.
|
||||||
|
Shown when the user taps the info icon in a room thread. -->
|
||||||
|
<div id="chat-room-info-view" class="chat-view hidden">
|
||||||
|
<div id="chat-room-info-header">
|
||||||
|
<button id="chat-room-info-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-users-three text-t-accent text-sm"></i>
|
||||||
|
<span id="chat-room-info-name" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-room-info-body" class="chat-room-info-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+427
-106
@@ -3,6 +3,12 @@
|
|||||||
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
||||||
// (faster while open, slower while closed). Blinks the footer button
|
// (faster while open, slower while closed). Blinks the footer button
|
||||||
// when new incoming messages arrive while the flyout is closed.
|
// when new incoming messages arrive while the flyout is closed.
|
||||||
|
//
|
||||||
|
// Threads come in two kinds:
|
||||||
|
// peer — classic 1:1 with a single relay tenant. Disposable flag per
|
||||||
|
// thread drops messages from the relay after delivery.
|
||||||
|
// room — named group chat with a member list. Always persistent on
|
||||||
|
// the relay so every device / late joiner has full history.
|
||||||
|
|
||||||
const hyprcrd = window.hyprcrd;
|
const hyprcrd = window.hyprcrd;
|
||||||
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
||||||
@@ -12,16 +18,18 @@ const $ = (id) => document.getElementById(id);
|
|||||||
// ── State ─────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let conversations = [];
|
let conversations = [];
|
||||||
let currentPeer = null;
|
// Null = list view. Otherwise: { kind: 'peer'|'room', key, label }.
|
||||||
|
let currentThread = null;
|
||||||
let currentMessages = [];
|
let currentMessages = [];
|
||||||
let pollHandle = null;
|
let pollHandle = null;
|
||||||
let pollIntervalMs = 20000; // closed default
|
let pollIntervalMs = 20000;
|
||||||
let recipientCache = null; // { tenants: [...], self: '...' }
|
let recipientCache = null; // { tenants: [...], self: '...' }
|
||||||
let initialPollDone = false;
|
let initialPollDone = false;
|
||||||
|
|
||||||
// Chat polls aggressively because the endpoint is cheap (single indexed
|
// Group creation pick-view state. Reset on every pick-view open.
|
||||||
// SELECT with LIMIT 500). Card sync stays on its own slower interval —
|
let pickMode = 'peer'; // 'peer' or 'group'
|
||||||
// CalDAV is the expensive one. Tab-hidden pauses the timer entirely.
|
let selectedMembers = new Set();
|
||||||
|
|
||||||
const POLL_OPEN_MS = 8000;
|
const POLL_OPEN_MS = 8000;
|
||||||
const POLL_CLOSED_MS = 20000;
|
const POLL_CLOSED_MS = 20000;
|
||||||
|
|
||||||
@@ -49,6 +57,36 @@ function isOpen() {
|
|||||||
return !$('chat-flyout')?.classList.contains('hidden');
|
return !$('chat-flyout')?.classList.contains('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function threadFromItem(item) {
|
||||||
|
return { kind: item.kind || 'peer', key: item.key ?? item.peer, label: item.label ?? item.peer };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameThread(a, b) {
|
||||||
|
return a && b && a.kind === b.kind && a.key === b.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadBody(thread) {
|
||||||
|
return thread.kind === 'room' ? { room_uid: thread.key } : { peer: thread.key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadStorageKey(thread) {
|
||||||
|
return `${thread.kind}:${thread.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorageKey(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const i = str.indexOf(':');
|
||||||
|
if (i < 0) return { kind: 'peer', key: str, label: str };
|
||||||
|
return { kind: str.slice(0, i), key: str.slice(i + 1), label: str.slice(i + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSelfUsername() {
|
||||||
|
if (recipientCache?.self) return recipientCache.self;
|
||||||
|
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
||||||
|
if (res?.ok) recipientCache = res;
|
||||||
|
return recipientCache?.self || '';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Footer button ─────────────────────────────────────────────────
|
// ── Footer button ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function injectFooterButton() {
|
function injectFooterButton() {
|
||||||
@@ -56,7 +94,6 @@ function injectFooterButton() {
|
|||||||
if (!footer) return;
|
if (!footer) return;
|
||||||
if (document.getElementById('chat-footer-btn')) 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');
|
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
|
||||||
if (!rightGroup) return;
|
if (!rightGroup) return;
|
||||||
|
|
||||||
@@ -101,29 +138,29 @@ async function toggleFlyout() {
|
|||||||
|
|
||||||
async function openFlyout() {
|
async function openFlyout() {
|
||||||
$('chat-flyout').classList.remove('hidden');
|
$('chat-flyout').classList.remove('hidden');
|
||||||
// Capture blink state BEFORE we clear it — it tells us a peer wrote since last close
|
|
||||||
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
|
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
|
||||||
stopBlink();
|
stopBlink();
|
||||||
pollIntervalMs = POLL_OPEN_MS;
|
pollIntervalMs = POLL_OPEN_MS;
|
||||||
restartPoll();
|
restartPoll();
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
await pollOnce(); // immediate refresh on open
|
await pollOnce();
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
|
|
||||||
// Pick the right initial view:
|
// Initial view: newest unread thread after a blink, else the last
|
||||||
// 1. If blinking → newest peer with unread (= someone wrote to us)
|
// thread the user opened (peer or room), else the list.
|
||||||
// 2. Else → last peer the user opened manually (persisted)
|
|
||||||
// 3. Else → list view
|
|
||||||
let target = null;
|
let target = null;
|
||||||
if (wasBlinking) {
|
if (wasBlinking) {
|
||||||
const unread = conversations
|
const unread = conversations
|
||||||
.filter(c => parseInt(c.unread) > 0)
|
.filter(c => parseInt(c.unread) > 0)
|
||||||
.sort((a, b) => String(b.last_at).localeCompare(String(a.last_at)));
|
.sort((a, b) => String(b.last_at || '').localeCompare(String(a.last_at || '')));
|
||||||
if (unread.length) target = unread[0].peer;
|
if (unread.length) target = threadFromItem(unread[0]);
|
||||||
}
|
}
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const lastPeer = localStorage.getItem('chat-last-peer');
|
const remembered = parseStorageKey(localStorage.getItem('chat-last-thread'));
|
||||||
if (lastPeer && conversations.some(c => c.peer === lastPeer)) target = lastPeer;
|
if (remembered && conversations.some(c => c.kind === remembered.kind && c.key === remembered.key)) {
|
||||||
|
const item = conversations.find(c => c.kind === remembered.kind && c.key === remembered.key);
|
||||||
|
target = threadFromItem(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (target) await openConversation(target);
|
if (target) await openConversation(target);
|
||||||
else showListView();
|
else showListView();
|
||||||
@@ -137,31 +174,48 @@ function closeFlyout() {
|
|||||||
|
|
||||||
// ── Views ─────────────────────────────────────────────────────────
|
// ── Views ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showListView() {
|
function hideAllViews() {
|
||||||
$('chat-list-view').classList.remove('hidden');
|
['chat-list-view', 'chat-thread-view', 'chat-pick-view', 'chat-room-info-view']
|
||||||
$('chat-thread-view').classList.add('hidden');
|
.forEach(id => $(id)?.classList.add('hidden'));
|
||||||
$('chat-pick-view').classList.add('hidden');
|
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
|
||||||
$('chat-header-back')?.classList.add('hidden');
|
|
||||||
currentPeer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showThreadView(peer) {
|
function showListView() {
|
||||||
$('chat-list-view').classList.add('hidden');
|
hideAllViews();
|
||||||
|
$('chat-list-view').classList.remove('hidden');
|
||||||
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
|
$('chat-header-back')?.classList.add('hidden');
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
|
currentThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThreadView(thread) {
|
||||||
|
hideAllViews();
|
||||||
$('chat-thread-view').classList.remove('hidden');
|
$('chat-thread-view').classList.remove('hidden');
|
||||||
$('chat-pick-view').classList.add('hidden');
|
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
$('chat-header-back')?.classList.remove('hidden');
|
$('chat-header-back')?.classList.remove('hidden');
|
||||||
currentPeer = peer;
|
currentThread = thread;
|
||||||
// Re-render tabs so the active highlight follows the thread switch.
|
// Room-only: info icon. 1:1-only: disposable icon.
|
||||||
|
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
|
||||||
|
$('chat-thread-disposable')?.classList.toggle('hidden', thread.kind !== 'peer');
|
||||||
|
if (thread.kind === 'peer') refreshDisposableIcon(thread.key);
|
||||||
renderPeerTabs();
|
renderPeerTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPickView() {
|
function showPickView() {
|
||||||
$('chat-list-view').classList.add('hidden');
|
hideAllViews();
|
||||||
$('chat-thread-view').classList.add('hidden');
|
|
||||||
$('chat-pick-view').classList.remove('hidden');
|
$('chat-pick-view').classList.remove('hidden');
|
||||||
$('chat-header-back')?.classList.add('hidden');
|
$('chat-header-back')?.classList.add('hidden');
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRoomInfoView() {
|
||||||
|
hideAllViews();
|
||||||
|
$('chat-room-info-view').classList.remove('hidden');
|
||||||
|
$('chat-header-back')?.classList.add('hidden');
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversation list ─────────────────────────────────────────────
|
// ── Conversation list ─────────────────────────────────────────────
|
||||||
@@ -186,19 +240,29 @@ function renderConversations() {
|
|||||||
}
|
}
|
||||||
empty.classList.add('hidden');
|
empty.classList.add('hidden');
|
||||||
list.innerHTML = conversations.map(c => {
|
list.innerHTML = conversations.map(c => {
|
||||||
const initial = (c.peer || '?').charAt(0).toUpperCase();
|
const isRoom = c.kind === 'room';
|
||||||
|
const label = c.label || c.key || '?';
|
||||||
|
const initial = label.charAt(0).toUpperCase();
|
||||||
|
const avatar = isRoom
|
||||||
|
? '<div class="chat-list-avatar"><i class="ph ph-users-three"></i></div>'
|
||||||
|
: `<div class="chat-list-avatar">${escapeHtml(initial)}</div>`;
|
||||||
const unreadBadge = parseInt(c.unread) > 0
|
const unreadBadge = parseInt(c.unread) > 0
|
||||||
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
||||||
const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview';
|
const previewClass = (!isRoom && c.last_direction === 'out')
|
||||||
|
? 'chat-list-preview outgoing'
|
||||||
|
: 'chat-list-preview';
|
||||||
|
const preview = isRoom && c.last_author
|
||||||
|
? `${escapeHtml(c.last_author)}: ${escapeHtml((c.last_body || '').slice(0, 70))}`
|
||||||
|
: escapeHtml((c.last_body || '').slice(0, 80));
|
||||||
return `
|
return `
|
||||||
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}">
|
<div class="chat-list-row" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}">
|
||||||
<div class="chat-list-avatar">${escapeHtml(initial)}</div>
|
${avatar}
|
||||||
<div class="chat-list-info">
|
<div class="chat-list-info">
|
||||||
<div class="chat-list-peer">
|
<div class="chat-list-peer">
|
||||||
<span>${escapeHtml(c.peer)}</span>
|
<span>${escapeHtml(label)}</span>
|
||||||
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div>
|
<div class="${previewClass}">${preview}</div>
|
||||||
</div>
|
</div>
|
||||||
${unreadBadge}
|
${unreadBadge}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +270,10 @@ function renderConversations() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
list.querySelectorAll('.chat-list-row').forEach(row => {
|
list.querySelectorAll('.chat-list-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
row.addEventListener('click', () => {
|
||||||
|
const item = conversations.find(c => c.kind === row.dataset.kind && c.key === row.dataset.key);
|
||||||
|
if (item) openConversation(threadFromItem(item));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,36 +285,36 @@ function renderConversations() {
|
|||||||
function renderPeerTabs() {
|
function renderPeerTabs() {
|
||||||
const strip = $('chat-thread-peers');
|
const strip = $('chat-thread-peers');
|
||||||
if (!strip) return;
|
if (!strip) return;
|
||||||
// Fold the current peer in even if no messages exist yet (fresh
|
const tabs = conversations.slice();
|
||||||
// pick-view selection), so switching back to it via tab is always
|
// Fold the current thread in even if it's brand-new (e.g. just
|
||||||
// possible.
|
// created a room and haven't synced conversations yet), so the
|
||||||
const peers = conversations.slice();
|
// active tab is always visible.
|
||||||
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
|
if (currentThread && !tabs.some(c => c.kind === currentThread.kind && c.key === currentThread.key)) {
|
||||||
peers.unshift({ peer: currentPeer, unread: 0 });
|
tabs.unshift({ kind: currentThread.kind, key: currentThread.key, label: currentThread.label, unread: 0 });
|
||||||
}
|
}
|
||||||
if (!peers.length) { strip.innerHTML = ''; return; }
|
if (!tabs.length) { strip.innerHTML = ''; return; }
|
||||||
strip.innerHTML = peers.map(c => {
|
strip.innerHTML = tabs.map(c => {
|
||||||
const active = c.peer === currentPeer ? ' active' : '';
|
const isRoom = c.kind === 'room';
|
||||||
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
|
const active = currentThread && currentThread.kind === c.kind && currentThread.key === c.key ? ' active' : '';
|
||||||
|
const notActive = !active;
|
||||||
|
const dot = parseInt(c.unread) > 0 && notActive
|
||||||
? '<span class="chat-peer-dot"></span>' : '';
|
? '<span class="chat-peer-dot"></span>' : '';
|
||||||
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
|
const icon = isRoom
|
||||||
|
? '<i class="ph ph-users-three chat-peer-tab-icon"></i>'
|
||||||
|
: '';
|
||||||
|
const label = c.label || c.key || '?';
|
||||||
|
return `<button class="chat-peer-tab${active}" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}" title="${escapeHtml(label)}">${dot}${icon}<span>${escapeHtml(label)}</span></button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
|
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
const peer = tab.dataset.peer;
|
const thread = { kind: tab.dataset.kind, key: tab.dataset.key, label: tab.querySelector('span')?.textContent || tab.dataset.key };
|
||||||
if (peer && peer !== currentPeer) openConversation(peer);
|
if (!sameThread(currentThread, thread)) openConversation(thread);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Keep the active tab in view after a switch so the user can see
|
|
||||||
// what's selected when the list overflows.
|
|
||||||
const active = strip.querySelector('.chat-peer-tab.active');
|
const active = strip.querySelector('.chat-peer-tab.active');
|
||||||
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect vertical wheel scrolls to horizontal scroll on the peer
|
|
||||||
// strip. Attached once on init — preventDefault only when we actually
|
|
||||||
// consume the delta, so a trackpad user's native horizontal gesture
|
|
||||||
// still works.
|
|
||||||
function wirePeerStripWheel() {
|
function wirePeerStripWheel() {
|
||||||
const strip = $('chat-thread-peers');
|
const strip = $('chat-thread-peers');
|
||||||
if (!strip || strip.dataset.wheelWired) return;
|
if (!strip || strip.dataset.wheelWired) return;
|
||||||
@@ -262,45 +329,74 @@ function wirePeerStripWheel() {
|
|||||||
|
|
||||||
// ── Thread ────────────────────────────────────────────────────────
|
// ── Thread ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function openConversation(peer) {
|
async function openConversation(thread) {
|
||||||
showThreadView(peer);
|
showThreadView(thread);
|
||||||
localStorage.setItem('chat-last-peer', peer);
|
localStorage.setItem('chat-last-thread', threadStorageKey(thread));
|
||||||
await loadThread(peer);
|
await loadThread(thread);
|
||||||
// Mark all incoming as read
|
// Backfill older messages from the relay on open so a second device /
|
||||||
await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {});
|
// cold cache catches up. Fire-and-forget: the re-render happens via
|
||||||
// Refresh badges in the background
|
// the subsequent conversations refresh and the next poll tick.
|
||||||
|
backfillThread(thread).catch(() => {});
|
||||||
|
await hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(thread) }).catch(() => {});
|
||||||
loadConversations();
|
loadConversations();
|
||||||
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadThread(peer) {
|
async function loadThread(thread) {
|
||||||
const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer))
|
const qs = thread.kind === 'room'
|
||||||
.then(r => r.json()).catch(() => null);
|
? 'room_uid=' + encodeURIComponent(thread.key)
|
||||||
|
: 'peer=' + encodeURIComponent(thread.key);
|
||||||
|
const res = await fetch('api/chat/messages?' + qs).then(r => r.json()).catch(() => null);
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
currentMessages = res.messages || [];
|
currentMessages = res.messages || [];
|
||||||
renderThread();
|
await renderThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderThread() {
|
async function backfillThread(thread) {
|
||||||
|
const body = threadBody(thread);
|
||||||
|
// Oldest id we have locally is the lower bound for the backfill —
|
||||||
|
// we want messages BEFORE it.
|
||||||
|
const lowest = currentMessages.reduce((min, m) => {
|
||||||
|
const id = Number(m.id || 0);
|
||||||
|
return min === 0 || (id > 0 && id < min) ? id : min;
|
||||||
|
}, 0);
|
||||||
|
const res = await hyprcrd.api('api/chat/backfill', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...body, before: lowest, limit: 100 },
|
||||||
|
}).catch(() => null);
|
||||||
|
if (res?.ok && (res.backfilled || 0) > 0 && sameThread(thread, currentThread)) {
|
||||||
|
await loadThread(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderThread() {
|
||||||
const wrap = $('chat-thread-messages');
|
const wrap = $('chat-thread-messages');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
const self = await getSelfUsername();
|
||||||
|
const isRoom = currentThread?.kind === 'room';
|
||||||
|
let lastAuthor = null;
|
||||||
wrap.innerHTML = currentMessages.map(m => {
|
wrap.innerHTML = currentMessages.map(m => {
|
||||||
const cls = m.direction === 'out' ? 'out' : 'in';
|
// For groups: author == self → "out" bubble regardless of the
|
||||||
// Read receipts for outgoing messages: single check = recipient
|
// stored direction (which reflects which device inserted the row).
|
||||||
// polled (delivered), double check = recipient opened the thread
|
const mine = isRoom ? (m.peer === self) : (m.direction === 'out');
|
||||||
// (seen). Incoming messages get no icon.
|
const cls = mine ? 'out' : 'in';
|
||||||
let receipt = '';
|
let receipt = '';
|
||||||
if (m.direction === 'out') {
|
if (!isRoom && mine) {
|
||||||
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
|
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
|
||||||
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
||||||
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
||||||
}
|
}
|
||||||
|
let authorLine = '';
|
||||||
|
if (isRoom && !mine && m.peer !== lastAuthor) {
|
||||||
|
authorLine = `<div class="chat-bubble-author">${escapeHtml(m.peer || '')}</div>`;
|
||||||
|
}
|
||||||
|
lastAuthor = m.peer;
|
||||||
return `
|
return `
|
||||||
|
${authorLine}
|
||||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
// Scroll to bottom
|
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +404,7 @@ async function sendMessage(e) {
|
|||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const input = $('chat-thread-input');
|
const input = $('chat-thread-input');
|
||||||
const body = input.value.trim();
|
const body = input.value.trim();
|
||||||
if (!body || !currentPeer) return;
|
if (!body || !currentThread) return;
|
||||||
const sendBtn = $('chat-thread-send');
|
const sendBtn = $('chat-thread-send');
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
@@ -316,7 +412,7 @@ async function sendMessage(e) {
|
|||||||
try {
|
try {
|
||||||
const res = await hyprcrd.api('api/chat/send', {
|
const res = await hyprcrd.api('api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { peer: currentPeer, body },
|
body: { ...threadBody(currentThread), body },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = [
|
const detail = [
|
||||||
@@ -334,7 +430,7 @@ async function sendMessage(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await loadThread(currentPeer);
|
await loadThread(currentThread);
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await hyprcrd.dialog.show({
|
await hyprcrd.dialog.show({
|
||||||
@@ -351,10 +447,15 @@ async function sendMessage(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pick recipient ────────────────────────────────────────────────
|
// ── Pick recipient / group creation ───────────────────────────────
|
||||||
|
|
||||||
async function openPickView() {
|
async function openPickView() {
|
||||||
showPickView();
|
showPickView();
|
||||||
|
pickMode = 'peer';
|
||||||
|
selectedMembers = new Set();
|
||||||
|
applyPickMode();
|
||||||
|
$('chat-group-name').value = '';
|
||||||
|
|
||||||
const list = $('chat-pick-list');
|
const list = $('chat-pick-list');
|
||||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||||
|
|
||||||
@@ -366,25 +467,243 @@ async function openPickView() {
|
|||||||
}
|
}
|
||||||
recipientCache = res;
|
recipientCache = res;
|
||||||
}
|
}
|
||||||
|
renderPickList();
|
||||||
|
}
|
||||||
|
|
||||||
const tenants = recipientCache.tenants || [];
|
function applyPickMode() {
|
||||||
|
const nameInput = $('chat-group-name');
|
||||||
|
const actions = $('chat-group-actions');
|
||||||
|
const title = $('chat-pick-title');
|
||||||
|
document.querySelectorAll('.chat-pick-mode-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.mode === pickMode);
|
||||||
|
});
|
||||||
|
if (pickMode === 'group') {
|
||||||
|
nameInput.classList.remove('hidden');
|
||||||
|
actions.classList.remove('hidden');
|
||||||
|
if (title) title.textContent = 'new group';
|
||||||
|
updateGroupCount();
|
||||||
|
} else {
|
||||||
|
nameInput.classList.add('hidden');
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
if (title) title.textContent = 'new conversation';
|
||||||
|
}
|
||||||
|
renderPickList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPickList() {
|
||||||
|
const list = $('chat-pick-list');
|
||||||
|
if (!list || !recipientCache) return;
|
||||||
const self = recipientCache.self;
|
const self = recipientCache.self;
|
||||||
const others = tenants.filter(t => t.username !== self);
|
const others = (recipientCache.tenants || []).filter(t => t.username !== self);
|
||||||
if (!others.length) {
|
if (!others.length) {
|
||||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = others.map(t => `
|
list.innerHTML = others.map(t => {
|
||||||
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}">
|
const selected = selectedMembers.has(t.username);
|
||||||
<i class="ph ph-user-circle text-sm"></i>
|
const selCls = (pickMode === 'group' && selected) ? ' selected' : '';
|
||||||
<span>${escapeHtml(t.username)}</span>
|
const check = pickMode === 'group'
|
||||||
</div>
|
? `<i class="ph ph-check chat-pick-check"></i>`
|
||||||
`).join('');
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="chat-pick-row${selCls}" data-peer="${escapeHtml(t.username)}">
|
||||||
|
<i class="ph ph-user-circle text-sm"></i>
|
||||||
|
<span>${escapeHtml(t.username)}</span>
|
||||||
|
${check}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
||||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
row.addEventListener('click', () => {
|
||||||
|
const u = row.dataset.peer;
|
||||||
|
if (pickMode === 'group') {
|
||||||
|
if (selectedMembers.has(u)) selectedMembers.delete(u);
|
||||||
|
else selectedMembers.add(u);
|
||||||
|
renderPickList();
|
||||||
|
updateGroupCount();
|
||||||
|
} else {
|
||||||
|
openConversation({ kind: 'peer', key: u, label: u });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateGroupCount() {
|
||||||
|
const el = $('chat-group-count');
|
||||||
|
if (!el) return;
|
||||||
|
const n = selectedMembers.size;
|
||||||
|
el.textContent = n ? `${n} member${n === 1 ? '' : 's'} selected` : 'pick at least one member';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
const name = $('chat-group-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'missing name', message: 'Give the group a name.', okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedMembers.size) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'no members', message: 'Pick at least one other member.', okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await hyprcrd.api('api/chat/rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, members: [...selectedMembers] },
|
||||||
|
});
|
||||||
|
if (!res?.ok) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'create failed',
|
||||||
|
message: res?.error || 'unknown error',
|
||||||
|
danger: true,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
cancelLabel: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadConversations();
|
||||||
|
openConversation({ kind: 'room', key: res.uid, label: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room info / member management ──────────────────────────────
|
||||||
|
|
||||||
|
async function openRoomInfo() {
|
||||||
|
if (currentThread?.kind !== 'room') return;
|
||||||
|
const savedThread = currentThread;
|
||||||
|
showRoomInfoView();
|
||||||
|
$('chat-room-info-name').textContent = savedThread.label || savedThread.key;
|
||||||
|
const body = $('chat-room-info-body');
|
||||||
|
body.innerHTML = '<div class="text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||||
|
|
||||||
|
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
|
||||||
|
const room = (res?.rooms || []).find(r => r.uid === savedThread.key);
|
||||||
|
const self = await getSelfUsername();
|
||||||
|
if (!room) {
|
||||||
|
body.innerHTML = '<div class="text-[10px] text-t-danger font-pixel">room not found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isOwner = room.owner_username === self;
|
||||||
|
const others = (res?.tenants || recipientCache?.tenants || [])
|
||||||
|
.filter(t => t.username !== self && !(room.members || []).includes(t.username));
|
||||||
|
|
||||||
|
const memberRows = (room.members || []).map(u => {
|
||||||
|
const ownerTag = u === room.owner_username ? '<span class="owner-badge">owner</span>' : '';
|
||||||
|
const removeBtn = (isOwner && u !== room.owner_username)
|
||||||
|
? `<button class="btn danger" data-action="remove" data-user="${escapeHtml(u)}" style="margin-left:auto;padding:2px 8px;">remove</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="chat-room-member-row">
|
||||||
|
<i class="ph ph-user-circle text-t-accent"></i>
|
||||||
|
<span>${escapeHtml(u)}</span>
|
||||||
|
${ownerTag}
|
||||||
|
${removeBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length
|
||||||
|
? `<div class="section-label">Add members</div>
|
||||||
|
<div id="chat-room-add-list">${
|
||||||
|
(recipientCache?.tenants || [])
|
||||||
|
.filter(t => t.username !== self && !(room.members || []).includes(t.username))
|
||||||
|
.map(t => `<div class="chat-room-member-row"><i class="ph ph-user-circle"></i><span>${escapeHtml(t.username)}</span><button class="btn" style="margin-left:auto;padding:2px 8px;" data-action="add" data-user="${escapeHtml(t.username)}">add</button></div>`)
|
||||||
|
.join('') || '<div class="text-[10px] text-t-dim font-pixel">everyone on the relay is already in</div>'
|
||||||
|
}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="section-label">Members (${(room.members || []).length})</div>
|
||||||
|
${memberRows || '<div class="text-[10px] text-t-dim">no members</div>'}
|
||||||
|
</div>
|
||||||
|
${addSection}
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
${isOwner
|
||||||
|
? '<button class="btn danger" data-action="delete" style="flex:1">Delete room</button>'
|
||||||
|
: '<button class="btn danger" data-action="leave" style="flex:1">Leave room</button>'
|
||||||
|
}
|
||||||
|
<button class="btn" data-action="back" style="flex:1">Back to chat</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.querySelectorAll('button[data-action]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => handleRoomAction(btn.dataset.action, btn.dataset.user, savedThread));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoomAction(action, user, thread) {
|
||||||
|
const uid = thread.key;
|
||||||
|
if (action === 'back') {
|
||||||
|
openConversation(thread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove' || action === 'add') {
|
||||||
|
// Fetch current room, mutate members, PATCH.
|
||||||
|
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
|
||||||
|
const room = (res?.rooms || []).find(r => r.uid === uid);
|
||||||
|
if (!room) return;
|
||||||
|
let members = [...(room.members || [])];
|
||||||
|
if (action === 'remove') members = members.filter(m => m !== user);
|
||||||
|
else if (!members.includes(user)) members.push(user);
|
||||||
|
// Relay adds owner automatically, but we forward the full list
|
||||||
|
// minus the owner (owner ALWAYS stays on relay side).
|
||||||
|
const patchRes = await hyprcrd.api(`api/chat/rooms/${uid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { members },
|
||||||
|
});
|
||||||
|
if (!patchRes?.ok) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'update failed', message: patchRes?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
}
|
||||||
|
await pollOnce(); // refresh local rooms mirror
|
||||||
|
await openRoomInfo(); // re-render
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'leave' || action === 'delete') {
|
||||||
|
const confirm = await hyprcrd.dialog.show({
|
||||||
|
title: action === 'delete' ? 'Delete room?' : 'Leave room?',
|
||||||
|
message: action === 'delete'
|
||||||
|
? 'All members lose access and the message history is wiped.'
|
||||||
|
: 'You will stop receiving messages in this room.',
|
||||||
|
danger: true,
|
||||||
|
okLabel: action === 'delete' ? 'Delete' : 'Leave',
|
||||||
|
});
|
||||||
|
if (!confirm) return;
|
||||||
|
const res = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'DELETE' });
|
||||||
|
if (!res?.ok) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'failed', message: res?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollOnce();
|
||||||
|
await loadConversations();
|
||||||
|
showListView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disposable toggle (1:1) ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshDisposableIcon(peer) {
|
||||||
|
const btn = $('chat-thread-disposable');
|
||||||
|
if (!btn) return;
|
||||||
|
const res = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json()).catch(() => null);
|
||||||
|
const on = !!res?.disposable;
|
||||||
|
btn.classList.toggle('active', on);
|
||||||
|
btn.setAttribute('data-tip', on
|
||||||
|
? 'disposable: new messages drop after delivery — click to persist'
|
||||||
|
: 'persistent: messages live on the relay — click to make disposable');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDisposable() {
|
||||||
|
if (currentThread?.kind !== 'peer') return;
|
||||||
|
const peer = currentThread.key;
|
||||||
|
const cur = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json()).catch(() => ({ disposable: 0 }));
|
||||||
|
const next = cur.disposable ? 0 : 1;
|
||||||
|
await hyprcrd.api('api/chat/thread-settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { peer, disposable: next },
|
||||||
|
});
|
||||||
|
refreshDisposableIcon(peer);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Polling ───────────────────────────────────────────────────────
|
// ── Polling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function pollOnce() {
|
async function pollOnce() {
|
||||||
@@ -397,20 +716,15 @@ async function pollOnce() {
|
|||||||
const receiptUpdates = res.receipts || 0;
|
const receiptUpdates = res.receipts || 0;
|
||||||
if (newCount > 0) {
|
if (newCount > 0) {
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
// If we're inside a thread for any peer, refresh + auto-read it
|
if (isOpen() && currentThread) {
|
||||||
if (isOpen() && currentPeer) {
|
await loadThread(currentThread);
|
||||||
await loadThread(currentPeer);
|
hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(currentThread) })
|
||||||
hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } })
|
|
||||||
.then(() => loadConversations()).catch(() => {});
|
.then(() => loadConversations()).catch(() => {});
|
||||||
}
|
}
|
||||||
// Blink if the flyout is closed
|
|
||||||
if (!isOpen()) startBlink();
|
if (!isOpen()) startBlink();
|
||||||
} else if (receiptUpdates > 0 && isOpen() && currentPeer) {
|
} else if (receiptUpdates > 0 && isOpen() && currentThread) {
|
||||||
// No new incoming, but receipts (delivered/seen) changed for
|
await loadThread(currentThread);
|
||||||
// our outgoing — refresh the thread so the check icons update.
|
|
||||||
await loadThread(currentPeer);
|
|
||||||
} else if (initialPollDone === false) {
|
} else if (initialPollDone === false) {
|
||||||
// First poll: refresh badges in case we have unread from a previous session
|
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
}
|
}
|
||||||
initialPollDone = true;
|
initialPollDone = true;
|
||||||
@@ -438,14 +752,26 @@ function init() {
|
|||||||
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
||||||
$('chat-pick-back').addEventListener('click', showListView);
|
$('chat-pick-back').addEventListener('click', showListView);
|
||||||
$('chat-thread-form').addEventListener('submit', sendMessage);
|
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||||
|
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
|
||||||
|
$('chat-thread-disposable')?.addEventListener('click', toggleDisposable);
|
||||||
|
$('chat-room-info-back')?.addEventListener('click', () => {
|
||||||
|
if (currentThread) openConversation(currentThread);
|
||||||
|
else showListView();
|
||||||
|
});
|
||||||
|
$('chat-group-create')?.addEventListener('click', createRoom);
|
||||||
|
document.querySelectorAll('.chat-pick-mode-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
pickMode = btn.dataset.mode;
|
||||||
|
applyPickMode();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
wirePeerStripWheel();
|
wirePeerStripWheel();
|
||||||
|
|
||||||
// Esc closes flyout when focus is inside it
|
|
||||||
$('chat-flyout').addEventListener('keydown', e => {
|
$('chat-flyout').addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer the first POST (poll) until auth has set csrf. GETs are fine immediately.
|
|
||||||
loadConversations().catch(() => {});
|
loadConversations().catch(() => {});
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
pollOnce().catch(() => {});
|
pollOnce().catch(() => {});
|
||||||
@@ -457,7 +783,6 @@ function init() {
|
|||||||
hyprcrd.hooks.on('csrf.ready', startPolling);
|
hyprcrd.hooks.on('csrf.ready', startPolling);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause polling when the page is hidden
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||||
@@ -467,10 +792,6 @@ function init() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also refresh on the global sync heartbeat. Our own timer keeps
|
|
||||||
// the faster flyout-open cadence (UX-specific); tick guarantees
|
|
||||||
// that the header Sync button refreshes chat too, even when the
|
|
||||||
// flyout is closed and our own timer sits at 20s.
|
|
||||||
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +803,6 @@ if (document.readyState === 'loading') {
|
|||||||
|
|
||||||
hyprcrd.registerPlugin?.({
|
hyprcrd.registerPlugin?.({
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
version: '1.0.0',
|
version: '1.1.0',
|
||||||
description: 'Encrypted chat between relay tenants',
|
description: 'Encrypted chat between relay tenants (1:1 + group rooms)',
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, click, $, sleep, waitFor } from './runner.js?v=4';
|
import { describe, it, expect, click, $, sleep, waitFor, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
// Chat plugin tests — UI-focused. No send/receive here: that would
|
// Chat plugin tests — UI-focused. No send/receive here: that would
|
||||||
// require a real relay, and the user's "no mocks" rule means we don't
|
// require a real relay, and the user's "no mocks" rule means we don't
|
||||||
@@ -86,9 +86,14 @@ describe('Chat: API', () => {
|
|||||||
const res = await fetch('api/chat/conversations').then(r => r.json());
|
const res = await fetch('api/chat/conversations').then(r => r.json());
|
||||||
expect(Array.isArray(res.conversations)).toBe(true);
|
expect(Array.isArray(res.conversations)).toBe(true);
|
||||||
expect(typeof res.total_unread).toBe('number');
|
expect(typeof res.total_unread).toBe('number');
|
||||||
|
// Every entry carries kind + key so the UI can route 1:1 vs. room.
|
||||||
|
for (const c of res.conversations) {
|
||||||
|
expect(c.kind === 'peer' || c.kind === 'room').toBe(true);
|
||||||
|
expect(typeof c.key).toBe('string');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/api/chat/messages without peer returns 400', async () => {
|
it('/api/chat/messages without peer or room_uid returns 400', async () => {
|
||||||
const r = await fetch('api/chat/messages');
|
const r = await fetch('api/chat/messages');
|
||||||
expect(r.status).toBe(400);
|
expect(r.status).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -99,4 +104,68 @@ describe('Chat: API', () => {
|
|||||||
expect(Array.isArray(res.messages)).toBe(true);
|
expect(Array.isArray(res.messages)).toBe(true);
|
||||||
expect(res.messages.length).toBe(0);
|
expect(res.messages.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('/api/chat/messages with unknown room returns empty array', async () => {
|
||||||
|
const res = await fetch('api/chat/messages?room_uid=__no_such_room__')
|
||||||
|
.then(r => r.json());
|
||||||
|
expect(Array.isArray(res.messages)).toBe(true);
|
||||||
|
expect(res.messages.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/chat/rooms returns rooms array', async () => {
|
||||||
|
const res = await fetch('api/chat/rooms').then(r => r.json());
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(Array.isArray(res.rooms)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/chat/thread-settings round-trips disposable flag', async () => {
|
||||||
|
const peer = '__test_disposable_flip__';
|
||||||
|
const initial = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json());
|
||||||
|
expect(initial.disposable).toBe(0);
|
||||||
|
const set = await fetch('api/chat/thread-settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ peer, disposable: true }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
expect(set.disposable).toBe(1);
|
||||||
|
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json());
|
||||||
|
expect(after.disposable).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chat: group creation UI', () => {
|
||||||
|
const ensureFlyoutClosed = async () => {
|
||||||
|
const f = $('#chat-flyout');
|
||||||
|
if (!f) return;
|
||||||
|
if (!f.classList.contains('hidden')) {
|
||||||
|
click('#chat-close-btn');
|
||||||
|
await waitFor(() => f.classList.contains('hidden'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('pick view has 1:1 / group mode switch', async () => {
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
click('#chat-footer-btn');
|
||||||
|
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
||||||
|
await sleep(80);
|
||||||
|
click('#chat-new-btn');
|
||||||
|
await sleep(60);
|
||||||
|
const mode = document.querySelectorAll('.chat-pick-mode-btn');
|
||||||
|
expect(mode.length).toBe(2);
|
||||||
|
// Toggling to group reveals the name input and action bar.
|
||||||
|
const groupBtn = document.querySelector('.chat-pick-mode-btn[data-mode="group"]');
|
||||||
|
click(groupBtn);
|
||||||
|
await sleep(30);
|
||||||
|
expect($('#chat-group-name').classList.contains('hidden')).toBe(false);
|
||||||
|
expect($('#chat-group-actions').classList.contains('hidden')).toBe(false);
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('room-info view element exists', () => {
|
||||||
|
expect($('#chat-room-info-view')).toExist();
|
||||||
|
expect($('#chat-room-info-back')).toExist();
|
||||||
|
expect($('#chat-room-info-body')).toExist();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+42
-2
@@ -189,6 +189,19 @@ $chatColNames = array_column($chatCols, 'name');
|
|||||||
if (!in_array('seen', $chatColNames)) {
|
if (!in_array('seen', $chatColNames)) {
|
||||||
$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 rooms: room_uid (nullable — set for group messages, NULL for
|
||||||
|
// 1:1). recipient_id stays non-null for 1:1; group rooms resolve recipients
|
||||||
|
// via chat_room_members. `persistent` controls retention: 1 = keep in the
|
||||||
|
// long-term store so multi-device / late-joiners can pull history, 0 = old
|
||||||
|
// "disposable" behaviour (prune after fetch window). 1:1 defaults to 1;
|
||||||
|
// group messages are always 1.
|
||||||
|
if (!in_array('room_uid', $chatColNames)) {
|
||||||
|
$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)');
|
||||||
|
}
|
||||||
|
if (!in_array('persistent', $chatColNames)) {
|
||||||
|
$db->exec('ALTER TABLE chat_messages ADD COLUMN persistent INTEGER NOT NULL DEFAULT 1');
|
||||||
|
}
|
||||||
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
|
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
|
||||||
// the chat thread. The feature moved to its own `relay_sync_events` queue
|
// the chat thread. The feature moved to its own `relay_sync_events` queue
|
||||||
// (see below). Drop any leftover bot rows so they stop showing up in
|
// (see below). Drop any leftover bot rows so they stop showing up in
|
||||||
@@ -199,6 +212,27 @@ if (in_array('bot', $chatColNames)) {
|
|||||||
}
|
}
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
||||||
|
|
||||||
|
// Chat rooms: named group threads. Owner + membership mirrors collab
|
||||||
|
// buckets. Group messages live in chat_messages with room_uid set.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
|
||||||
|
room_uid TEXT NOT NULL,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (room_uid, tenant_id),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
|
||||||
|
|
||||||
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
||||||
// client syncs a card they received, they POST here; the sender polls
|
// client syncs a card they received, they POST here; the sender polls
|
||||||
// GET /api/sync-events and surfaces each as a transient toast. Rows are
|
// GET /api/sync-events and surfaces each as a transient toast. Rows are
|
||||||
@@ -315,8 +349,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
|
|||||||
// Cleanup: 24h ephemeral storage, expired sessions, old 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 messages WHERE created_at < datetime('now', '-24 hours')");
|
||||||
$db->exec("DELETE FROM attachments 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
|
// Chat retention:
|
||||||
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
// - persistent = 1 (default, 1:1 non-disposable + all group messages)
|
||||||
|
// → never auto-deleted. Relay is the source of truth so late joiners
|
||||||
|
// and second devices can backfill full history.
|
||||||
|
// - persistent = 0 (disposable 1:1 threads) → drop after a 7-day grace
|
||||||
|
// window so the sender's /api/chat/sent receipt poll still sees the
|
||||||
|
// final state.
|
||||||
|
$db->exec("DELETE FROM chat_messages WHERE persistent = 0 AND created_at < datetime('now', '-7 days')");
|
||||||
// Sync events are transient toasts — drop fetched ones after an hour,
|
// Sync events are transient toasts — drop fetched ones after an hour,
|
||||||
// everything after 24h regardless (sender was offline too long to care).
|
// everything after 24h regardless (sender was offline too long to care).
|
||||||
$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')");
|
$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')");
|
||||||
|
|||||||
+257
-20
@@ -154,16 +154,133 @@ if ($route === '/api/sync-events' && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Chat rooms (group chat) ──────────────────────────────────
|
||||||
|
// Mirrors the collab-bucket pattern: named container + member list.
|
||||||
|
// Group messages live in chat_messages with room_uid set (recipient_id
|
||||||
|
// is left 0 / NULL — fan-out happens via the join on chat_room_members
|
||||||
|
// at GET time instead of duplicating rows per recipient). Rooms are
|
||||||
|
// always persistent — message retention ignores the 7-day disposable
|
||||||
|
// window.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['uid'] ?? ''));
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400);
|
||||||
|
$exists = $db->prepare('SELECT 1 FROM chat_rooms WHERE uid = :u');
|
||||||
|
$exists->execute(['u' => $uid]);
|
||||||
|
if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409);
|
||||||
|
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($memberUsernames as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '' || $u === $clientTenant['username']) continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
$db->prepare('INSERT INTO chat_rooms (uid, owner_id, owner_username, name) VALUES (:uid, :oid, :ou, :n)')
|
||||||
|
->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name]);
|
||||||
|
$mstmt = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$mstmt->execute(['r' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT r.uid, r.name, r.owner_username, r.created_at
|
||||||
|
FROM chat_rooms r
|
||||||
|
INNER JOIN chat_room_members m ON m.room_uid = r.uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $clientTenant['id']]);
|
||||||
|
$rooms = $stmt->fetchAll();
|
||||||
|
$mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :uid ORDER BY username');
|
||||||
|
foreach ($rooms as &$r) {
|
||||||
|
$mstmt->execute(['uid' => $r['uid']]);
|
||||||
|
$r['members'] = array_column($mstmt->fetchAll(), 'username');
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'rooms' => $rooms]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$room = $row->fetch();
|
||||||
|
if (!$room) jsonResponse(['error' => 'room not found'], 404);
|
||||||
|
if ((int)$room['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403);
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
if (isset($input['name'])) {
|
||||||
|
$name = trim((string)$input['name']);
|
||||||
|
if ($name === '') jsonResponse(['error' => 'name cannot be empty'], 400);
|
||||||
|
$db->prepare('UPDATE chat_rooms SET name = :n WHERE uid = :u')->execute(['n' => $name, 'u' => $uid]);
|
||||||
|
}
|
||||||
|
if (isset($input['members']) && is_array($input['members'])) {
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($input['members'] as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '') continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
// Owner always stays a member.
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$ins = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$ins->execute(['r' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$room = $row->fetch();
|
||||||
|
if (!$room) jsonResponse(['error' => 'room not found'], 404);
|
||||||
|
|
||||||
|
if ((int)$room['owner_id'] === (int)$clientTenant['id']) {
|
||||||
|
$db->prepare('DELETE FROM chat_messages WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'deleted']);
|
||||||
|
} else {
|
||||||
|
$del = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u AND tenant_id = :t');
|
||||||
|
$del->execute(['u' => $uid, 't' => $clientTenant['id']]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'left']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat' && $method === 'POST') {
|
if ($route === '/api/chat' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$encryptedBody = (string)($input['body'] ?? '');
|
$encryptedBody = (string)($input['body'] ?? '');
|
||||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
|
$disposable = !empty($input['disposable']);
|
||||||
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400);
|
||||||
|
if ($recipientName !== '' && $roomUid !== '') jsonResponse(['error' => 'pick either recipient or room_uid, not both'], 400);
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
||||||
|
|
||||||
if (empty($clientTenant['secret_encrypted'])) {
|
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);
|
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||||
@@ -177,13 +294,28 @@ if ($route === '/api/chat' && $method === 'POST') {
|
|||||||
if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400);
|
if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400);
|
||||||
$stored = clientEncrypt($plain, $interKey);
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
if ($roomUid !== '') {
|
||||||
->execute([
|
// Group message: verify membership. `recipient_id` is NOT NULL +
|
||||||
'r' => $recipient['id'],
|
// has a tenants-FK, so we set it to the sender's own id as a
|
||||||
's' => $clientTenant['id'],
|
// placeholder — GET queries key off room_uid, never recipient_id,
|
||||||
'sn' => $clientTenant['username'],
|
// for group rows so this doesn't create a self-message artefact.
|
||||||
'b' => $stored,
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
]);
|
$check->execute(['r' => $roomUid, 't' => $clientTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)')
|
||||||
|
->execute(['me' => $clientTenant['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]);
|
||||||
|
} else {
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)')
|
||||||
|
->execute([
|
||||||
|
'r' => $recipient['id'],
|
||||||
|
's' => $clientTenant['id'],
|
||||||
|
'sn' => $clientTenant['username'],
|
||||||
|
'b' => $stored,
|
||||||
|
'p' => $disposable ? 0 : 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +326,24 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$since = (int)($_GET['since'] ?? 0);
|
$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');
|
// Return 1:1 messages addressed to me + group messages for rooms I'm
|
||||||
$stmt->execute(['r' => $clientTenant['id'], 's' => $since]);
|
// in (including my own group sends, so multi-device picks them up —
|
||||||
|
// the client dedups on relay_id). Exclude my own 1:1 sends — the
|
||||||
|
// sender has them locally already and the /api/chat/sent endpoint
|
||||||
|
// covers cross-device state for 1:1.
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_id, sender_username, recipient_id, room_uid, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE id > :s
|
||||||
|
AND (
|
||||||
|
(recipient_id = :me AND room_uid IS NULL)
|
||||||
|
OR (room_uid IS NOT NULL AND room_uid IN (
|
||||||
|
SELECT room_uid FROM chat_room_members WHERE tenant_id = :me2
|
||||||
|
))
|
||||||
|
)
|
||||||
|
ORDER BY id ASC LIMIT 500
|
||||||
|
');
|
||||||
|
$stmt->execute(['me' => $clientTenant['id'], 'me2' => $clientTenant['id'], 's' => $since]);
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
@@ -207,20 +355,106 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
$maxId = $since;
|
$maxId = $since;
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
||||||
$row['body'] = clientEncrypt($plain, $recipientSecret);
|
$out[] = [
|
||||||
$out[] = $row;
|
'id' => (int)$row['id'],
|
||||||
|
'sender_id' => (int)$row['sender_id'],
|
||||||
|
'sender_username' => $row['sender_username'],
|
||||||
|
'room_uid' => $row['room_uid'],
|
||||||
|
'body' => clientEncrypt($plain, $recipientSecret),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'],
|
||||||
|
];
|
||||||
if ((int)$row['id'] > $maxId) $maxId = (int)$row['id'];
|
if ((int)$row['id'] > $maxId) $maxId = (int)$row['id'];
|
||||||
}
|
}
|
||||||
// Mark fetched so cleanup can prune them sooner if desired. We still
|
// Mark fetched on 1:1 messages so disposable ones can be pruned
|
||||||
// keep them around for the 7-day window so a re-poll with since=0
|
// sooner. Group messages stay persistent regardless — no per-poll
|
||||||
// backfills history if a client wipes its local cache.
|
// fan-out state, so nothing to flag there.
|
||||||
if ($out) {
|
if ($out) {
|
||||||
$db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
|
$db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND room_uid IS NULL AND id <= :m AND fetched = 0')
|
||||||
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
||||||
}
|
}
|
||||||
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /api/chat/backfill ─────────────────────────────────────
|
||||||
|
// Scrollback / cold-start backfill for a single thread. Pulls older
|
||||||
|
// messages before a given id, re-encrypted for the caller. Used when
|
||||||
|
// the client opens a room it's never seen, or when the local cache
|
||||||
|
// was wiped on a second device.
|
||||||
|
// ?room_uid=X — group scrollback (membership checked)
|
||||||
|
// ?peer=X — 1:1 scrollback with that peer
|
||||||
|
// &before=N — only messages with id < N (default: latest)
|
||||||
|
// &limit=N — page size (default 100, max 500)
|
||||||
|
|
||||||
|
if ($route === '/api/chat/backfill' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$roomUid = trim((string)($_GET['room_uid'] ?? ''));
|
||||||
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
|
$before = (int)($_GET['before'] ?? 0);
|
||||||
|
$limit = max(1, min(500, (int)($_GET['limit'] ?? 100)));
|
||||||
|
if ($roomUid === '' && $peer === '') jsonResponse(['error' => 'room_uid or peer required'], 400);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$beforeClause = $before > 0 ? 'AND id < :before' : '';
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $clientTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
||||||
|
FROM chat_messages WHERE room_uid = :ru $beforeClause
|
||||||
|
ORDER BY id DESC LIMIT :lim";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->bindValue(':ru', $roomUid);
|
||||||
|
if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
|
if (!$peerTenant) jsonResponse(['error' => "no such user: $peer"], 404);
|
||||||
|
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE room_uid IS NULL
|
||||||
|
AND (
|
||||||
|
(recipient_id = :me AND sender_id = :peerid)
|
||||||
|
OR (recipient_id = :peerid2 AND sender_id = :me2)
|
||||||
|
)
|
||||||
|
$beforeClause
|
||||||
|
ORDER BY id DESC LIMIT :lim";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->bindValue(':me', $clientTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':me2', $clientTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':peerid', $peerTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':peerid2', $peerTenant['id'], PDO::PARAM_INT);
|
||||||
|
if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
// Flip back to ASC for a natural render order on the client.
|
||||||
|
$rows = array_reverse($rows);
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'sender_id' => (int)$row['sender_id'],
|
||||||
|
'sender_username' => $row['sender_username'],
|
||||||
|
'room_uid' => $row['room_uid'],
|
||||||
|
'body' => clientEncrypt($plain, $recipientSecret),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['messages' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /api/chat/sent ─────────────────────────────────────────
|
// ── GET /api/chat/sent ─────────────────────────────────────────
|
||||||
// Returns the receipt state (fetched/seen) for messages the caller has
|
// Returns the receipt state (fetched/seen) for messages the caller has
|
||||||
// sent. Body is intentionally NOT included — the sender already has the
|
// sent. Body is intentionally NOT included — the sender already has the
|
||||||
@@ -230,10 +464,13 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
if ($route === '/api/chat/sent' && $method === 'GET') {
|
if ($route === '/api/chat/sent' && $method === 'GET') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$since = (int)($_GET['since'] ?? 0);
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
|
// 1:1 only — group messages use the same sender_id but have no
|
||||||
|
// per-recipient receipt state, so including them would stall the
|
||||||
|
// cursor on rows that never reach seen=1.
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT id, recipient_id, fetched, seen, created_at
|
SELECT id, recipient_id, fetched, seen, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE sender_id = :s AND id > :since
|
WHERE sender_id = :s AND room_uid IS NULL AND id > :since
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
');
|
');
|
||||||
|
|||||||
Reference in new issue
Block a user