ArunKr commited on
Commit
bc4d206
·
verified ·
1 Parent(s): 340aad1

Upload folder using huggingface_hub

Browse files
TASKS.md CHANGED
@@ -23,7 +23,7 @@ Legend:
23
  - [x] Landing + route split (`/` landing, `/login`, `/app`) and UI redirects updated.
24
  - [x] Split `static/dashboard.html` into JS/CSS files (`static/dashboard.js`, `static/dashboard.css`).
25
  - [x] Theme tokens shared across login + dashboard (single source of truth via `static/theme.css`).
26
- - [~] Separate Settings vs Admin dashboard (lightweight `/settings` and `/admin` entry pages; full dedicated pages pending).
27
 
28
  ## P2 — Provider auth parity (Codex/Gemini/Claude)
29
  - [x] Codex auth file generation from env/secrets (`~/.codex/.auth.json` and `~/.codex/auth.json`).
 
23
  - [x] Landing + route split (`/` landing, `/login`, `/app`) and UI redirects updated.
24
  - [x] Split `static/dashboard.html` into JS/CSS files (`static/dashboard.js`, `static/dashboard.css`).
25
  - [x] Theme tokens shared across login + dashboard (single source of truth via `static/theme.css`).
26
+ - [~] Separate Settings vs Admin dashboard (deep links + admin feature overrides done; full dedicated pages pending).
27
 
28
  ## P2 — Provider auth parity (Codex/Gemini/Claude)
29
  - [x] Codex auth file generation from env/secrets (`~/.codex/.auth.json` and `~/.codex/auth.json`).
app/feature_overrides.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from typing import Any
7
+
8
+ from app.storage import global_data_dir
9
+
10
+ _LOCK = threading.Lock()
11
+ _CACHE_AT: float = 0.0
12
+ _CACHE: dict[str, bool] = {}
13
+ _TTL_SEC = 3.0
14
+ _PATH = global_data_dir() / "feature-overrides.json"
15
+
16
+
17
+ def load_feature_overrides() -> dict[str, bool]:
18
+ """
19
+ Loads persisted feature overrides (admin-managed).
20
+
21
+ Shape: {"version": 1, "overrides": {"terminal": true, "mcp": false, ...}}
22
+ """
23
+ global _CACHE_AT, _CACHE
24
+ now = time.monotonic()
25
+ if now - _CACHE_AT < _TTL_SEC:
26
+ return dict(_CACHE)
27
+
28
+ with _LOCK:
29
+ now = time.monotonic()
30
+ if now - _CACHE_AT < _TTL_SEC:
31
+ return dict(_CACHE)
32
+ path = _PATH
33
+ try:
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ except FileNotFoundError:
36
+ _CACHE = {}
37
+ _CACHE_AT = now
38
+ return {}
39
+ except Exception:
40
+ _CACHE_AT = now
41
+ return dict(_CACHE)
42
+
43
+ overrides = data.get("overrides") if isinstance(data, dict) else None
44
+ out: dict[str, bool] = {}
45
+ if isinstance(overrides, dict):
46
+ for k, v in overrides.items():
47
+ key = str(k).strip()
48
+ if not key:
49
+ continue
50
+ if isinstance(v, bool):
51
+ out[key] = v
52
+ _CACHE = out
53
+ _CACHE_AT = now
54
+ return dict(_CACHE)
55
+
56
+
57
+ def save_feature_overrides(overrides: dict[str, Any]) -> dict[str, bool]:
58
+ """
59
+ Persists a partial or full overrides dict (values must be bool).
60
+ """
61
+ global _CACHE_AT, _CACHE
62
+ out: dict[str, bool] = {}
63
+ for k, v in (overrides or {}).items():
64
+ key = str(k).strip()
65
+ if not key:
66
+ continue
67
+ if isinstance(v, bool):
68
+ out[key] = v
69
+
70
+ payload = {"version": 1, "overrides": out}
71
+ path = _PATH
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
74
+
75
+ with _LOCK:
76
+ _CACHE = dict(out)
77
+ _CACHE_AT = time.monotonic()
78
+ return out
app/routes/admin.py CHANGED
@@ -8,7 +8,9 @@ from fastapi import APIRouter, HTTPException, Request
8
  from pydantic import BaseModel
9
 
10
  from app.auth import require_user_from_request
 
11
  from app.routes.user import _is_admin
 
12
 
13
  router = APIRouter()
14
 
@@ -74,3 +76,37 @@ async def put_mcp_templates(body: McpTemplates, http_request: Request):
74
  return {"ok": True, "count": len(templates)}
75
  except Exception as e:
76
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from pydantic import BaseModel
9
 
10
  from app.auth import require_user_from_request
11
+ from app.feature_overrides import load_feature_overrides, save_feature_overrides
12
  from app.routes.user import _is_admin
13
+ from app.settings import feature_enabled
14
 
15
  router = APIRouter()
16
 
 
76
  return {"ok": True, "count": len(templates)}
77
  except Exception as e:
78
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
79
+
80
+
81
+ class FeatureOverridesBody(BaseModel):
82
+ overrides: dict[str, bool]
83
+
84
+
85
+ @router.get("/api/admin/features")
86
+ async def get_feature_overrides(http_request: Request):
87
+ user = await require_user_from_request(http_request)
88
+ _require_admin(user)
89
+ overrides = load_feature_overrides()
90
+ features = ["terminal", "codex", "mcp", "indexing", "rooms"]
91
+ return {
92
+ "ok": True,
93
+ "features": {
94
+ f: {"enabled": feature_enabled(f), "override": overrides.get(f)}
95
+ for f in features
96
+ },
97
+ "overrides": overrides,
98
+ }
99
+
100
+
101
+ @router.put("/api/admin/features")
102
+ async def put_feature_overrides(body: FeatureOverridesBody, http_request: Request):
103
+ user = await require_user_from_request(http_request)
104
+ _require_admin(user)
105
+ allowed = {"terminal", "codex", "mcp", "indexing", "rooms"}
106
+ overrides = {}
107
+ for k, v in (body.overrides or {}).items():
108
+ key = str(k).strip()
109
+ if key in allowed and isinstance(v, bool):
110
+ overrides[key] = v
111
+ saved = save_feature_overrides(overrides)
112
+ return {"ok": True, "overrides": saved}
app/routes/rooms.py CHANGED
@@ -200,12 +200,14 @@ async def websocket_rooms(websocket: WebSocket):
200
  text = _limit_text(str(msg.get("text") or ""))
201
  if not text:
202
  continue
 
 
 
203
  chat_msg = {
204
  "type": "chat.message",
205
- "id": str(uuid.uuid4()),
206
  "roomId": room_id,
207
  "ts": _now_iso(),
208
- "fromUserId": user_id,
209
  "fromDeviceId": device_id,
210
  "text": text,
211
  }
@@ -244,4 +246,3 @@ async def websocket_rooms(websocket: WebSocket):
244
  if not peers:
245
  websocket.app.state.rooms_connections.pop(room_id, None)
246
  await _broadcast(websocket.app, room_id, {"type": "presence.leave", "roomId": room_id, "deviceId": device_id})
247
-
 
200
  text = _limit_text(str(msg.get("text") or ""))
201
  if not text:
202
  continue
203
+ client_id = str(msg.get("clientId") or "").strip()
204
+ if not client_id or len(client_id) > 120:
205
+ client_id = str(uuid.uuid4())
206
  chat_msg = {
207
  "type": "chat.message",
208
+ "id": client_id,
209
  "roomId": room_id,
210
  "ts": _now_iso(),
 
211
  "fromDeviceId": device_id,
212
  "text": text,
213
  }
 
246
  if not peers:
247
  websocket.app.state.rooms_connections.pop(room_id, None)
248
  await _broadcast(websocket.app, room_id, {"type": "presence.leave", "roomId": room_id, "deviceId": device_id})
 
app/settings.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  import os
4
 
 
 
5
 
6
  def env_truthy(name: str, default: bool = False) -> bool:
7
  raw = os.environ.get(name)
@@ -15,12 +17,14 @@ def feature_enabled(feature: str) -> bool:
15
  Safety: when Supabase isn't configured, disable dangerous features by default.
16
  """
17
  has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
 
 
18
  defaults = {
19
- "terminal": has_supabase,
20
- "codex": has_supabase,
21
- "mcp": has_supabase,
22
  "indexing": False,
23
- "rooms": has_supabase,
24
  }
25
  env_map = {
26
  "terminal": "ENABLE_TERMINAL",
@@ -31,4 +35,9 @@ def feature_enabled(feature: str) -> bool:
31
  }
32
  if feature not in env_map:
33
  return False
34
- return env_truthy(env_map[feature], default=defaults[feature])
 
 
 
 
 
 
2
 
3
  import os
4
 
5
+ from app.feature_overrides import load_feature_overrides
6
+
7
 
8
  def env_truthy(name: str, default: bool = False) -> bool:
9
  raw = os.environ.get(name)
 
17
  Safety: when Supabase isn't configured, disable dangerous features by default.
18
  """
19
  has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
20
+ if not has_supabase:
21
+ return False
22
  defaults = {
23
+ "terminal": True,
24
+ "codex": True,
25
+ "mcp": True,
26
  "indexing": False,
27
+ "rooms": True,
28
  }
29
  env_map = {
30
  "terminal": "ENABLE_TERMINAL",
 
35
  }
36
  if feature not in env_map:
37
  return False
38
+
39
+ env_enabled = env_truthy(env_map[feature], default=defaults[feature])
40
+ overrides = load_feature_overrides()
41
+ if feature in overrides:
42
+ return bool(overrides[feature])
43
+ return env_enabled
docs/TROUBLESHOOTING.md CHANGED
@@ -69,6 +69,8 @@ HF Spaces generally supports PTYs, but custom runtimes may not.
69
 
70
  Set `ENABLE_ROOMS=1` in your environment and restart the container.
71
 
 
 
72
  ## P2P isn’t connecting in Rooms
73
 
74
  The Rooms view uses WebRTC DataChannels (optional, behind “Prefer P2P”). Some networks block UDP/WebRTC.
 
69
 
70
  Set `ENABLE_ROOMS=1` in your environment and restart the container.
71
 
72
+ Admins can also toggle feature overrides from Settings → Admin.
73
+
74
  ## P2P isn’t connecting in Rooms
75
 
76
  The Rooms view uses WebRTC DataChannels (optional, behind “Prefer P2P”). Some networks block UDP/WebRTC.
static/dashboard.html CHANGED
@@ -433,6 +433,43 @@
433
  <div class="text-xs text-gray-400 mb-2">Read-only admin status and server feature flags.</div>
434
  <div id="admin-status" class="text-sm text-gray-200">Loading...</div>
435
  <div id="admin-features" class="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-300"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  <div class="mt-3">
437
  <div class="text-xs font-semibold text-gray-400 mb-1 uppercase">MCP Templates (server)</div>
438
  <textarea id="admin-mcp-templates" rows="6"
 
433
  <div class="text-xs text-gray-400 mb-2">Read-only admin status and server feature flags.</div>
434
  <div id="admin-status" class="text-sm text-gray-200">Loading...</div>
435
  <div id="admin-features" class="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-300"></div>
436
+ <div class="mt-3 bg-gray-900/30 border border-gray-700 rounded-lg p-3 space-y-2">
437
+ <div class="flex items-center justify-between gap-2">
438
+ <div class="text-xs font-semibold text-gray-300 uppercase">Feature Overrides</div>
439
+ <div class="flex gap-2">
440
+ <button onclick="loadAdminFeatureOverrides()"
441
+ class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Load</button>
442
+ <button onclick="saveAdminFeatureOverrides()"
443
+ class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded text-xs">Save</button>
444
+ </div>
445
+ </div>
446
+ <div class="text-xs text-gray-400">
447
+ Overrides are persisted server-side (requires admin). Useful for temporarily disabling high-risk features without a redeploy.
448
+ </div>
449
+ <div class="grid grid-cols-2 gap-2 text-xs text-gray-200">
450
+ <label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
451
+ <input id="admin-override-terminal" type="checkbox" class="h-4 w-4 accent-blue-600">
452
+ Terminal
453
+ </label>
454
+ <label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
455
+ <input id="admin-override-codex" type="checkbox" class="h-4 w-4 accent-blue-600">
456
+ Codex
457
+ </label>
458
+ <label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
459
+ <input id="admin-override-mcp" type="checkbox" class="h-4 w-4 accent-blue-600">
460
+ MCP
461
+ </label>
462
+ <label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
463
+ <input id="admin-override-indexing" type="checkbox" class="h-4 w-4 accent-blue-600">
464
+ Indexing
465
+ </label>
466
+ <label class="flex items-center gap-2 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
467
+ <input id="admin-override-rooms" type="checkbox" class="h-4 w-4 accent-blue-600">
468
+ Rooms
469
+ </label>
470
+ </div>
471
+ <div id="admin-overrides-status" class="text-xs text-gray-500"></div>
472
+ </div>
473
  <div class="mt-3">
474
  <div class="text-xs font-semibold text-gray-400 mb-1 uppercase">MCP Templates (server)</div>
475
  <textarea id="admin-mcp-templates" rows="6"
static/dashboard.js CHANGED
@@ -197,7 +197,10 @@ let supabase;
197
  const admin = document.getElementById('admin-panel');
198
  if (admin) admin.scrollIntoView({ block: 'nearest' });
199
  // Best-effort auto-load templates for admins.
200
- setTimeout(() => loadAdminMcpTemplates(), 0);
 
 
 
201
  }
202
 
203
  function closeSettings() {
@@ -767,6 +770,72 @@ let supabase;
767
  }
768
  }
769
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  async function refreshCodexMcpServers() {
771
  const out = document.getElementById('codex-mcp-list');
772
  if (out) out.textContent = 'Loading...';
@@ -3094,6 +3163,10 @@ let supabase;
3094
  let roomsActiveRoomId = null;
3095
  let roomsPeers = [];
3096
  let roomsP2P = new Map(); // deviceId -> { pc, dc, polite, makingOffer, ignoreOffer }
 
 
 
 
3097
 
3098
  function getDeviceId() {
3099
  const key = 'device_id_v1';
@@ -3142,9 +3215,10 @@ let supabase;
3142
  box.appendChild(div);
3143
  }
3144
 
3145
- function appendRoomMessage(msg) {
3146
  const box = document.getElementById('rooms-messages');
3147
  if (!box) return;
 
3148
  const fromDevice = String(msg?.fromDeviceId || '').trim();
3149
  const isMe = fromDevice && fromDevice === getDeviceId();
3150
 
@@ -3156,15 +3230,22 @@ let supabase;
3156
  ? 'bg-blue-600/20 border-blue-500/30 text-gray-100'
3157
  : 'bg-gray-800/60 border-gray-700 text-gray-100'
3158
  }`;
 
3159
 
3160
  const meta = document.createElement('div');
3161
  meta.className = 'text-[11px] text-gray-400 mb-1 flex items-center justify-between gap-3';
3162
  const who = document.createElement('span');
3163
  who.textContent = isMe ? 'you' : (fromDevice ? fromDevice.slice(0, 8) : 'peer');
 
3164
  const ts = document.createElement('span');
3165
  ts.textContent = String(msg?.ts || '');
 
 
 
 
 
3166
  meta.appendChild(who);
3167
- meta.appendChild(ts);
3168
 
3169
  const text = document.createElement('div');
3170
  text.textContent = String(msg?.text || '');
@@ -3174,6 +3255,8 @@ let supabase;
3174
  wrap.appendChild(bubble);
3175
  box.appendChild(wrap);
3176
  box.scrollTop = box.scrollHeight;
 
 
3177
  }
3178
 
3179
  function renderRoomsList(rooms) {
@@ -3304,6 +3387,7 @@ let supabase;
3304
 
3305
  async function setActiveRoom(roomId, name) {
3306
  roomsActiveRoomId = String(roomId || '').trim();
 
3307
  updateRoomsHeader(roomsActiveRoomId, name || 'Room');
3308
  renderRoomsMessagesEmpty('Loading…');
3309
  await loadRoomsList();
@@ -3333,11 +3417,30 @@ let supabase;
3333
  }
3334
 
3335
  function disconnectRoomsWs() {
 
3336
  if (roomsWs) {
3337
  try { roomsWs.close(); } catch { }
3338
  roomsWs = null;
3339
  }
 
 
 
 
3340
  setRoomsConnStatus('Disconnected');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3341
  }
3342
 
3343
  async function connectRoomsWs() {
@@ -3359,12 +3462,14 @@ let supabase;
3359
  setRoomsConnStatus('Connecting…');
3360
 
3361
  roomsWs.onopen = () => {
 
3362
  setRoomsConnStatus('WS connected');
3363
  maybeStartRoomP2p();
3364
  };
3365
  roomsWs.onclose = () => {
3366
  setRoomsConnStatus('Disconnected');
3367
  closeAllRoomP2p();
 
3368
  };
3369
  roomsWs.onmessage = (event) => {
3370
  let msg;
@@ -3392,6 +3497,14 @@ let supabase;
3392
  return;
3393
  }
3394
  if (t === 'chat.message') {
 
 
 
 
 
 
 
 
3395
  appendRoomMessage(msg);
3396
  return;
3397
  }
@@ -3418,20 +3531,23 @@ let supabase;
3418
  const sent = sendRoomMessageViaP2p(text);
3419
  if (sent) return;
3420
  }
3421
- sendRoomsWs({ type: 'chat.send', text });
 
 
3422
  }
3423
 
3424
  function sendRoomMessageViaP2p(text) {
 
3425
  let any = false;
3426
  for (const entry of roomsP2P.values()) {
3427
  if (!entry?.dc || entry.dc.readyState !== 'open') continue;
3428
  try {
3429
- entry.dc.send(JSON.stringify({ type: 'chat.message', text, ts: new Date().toISOString() }));
3430
  any = true;
3431
  } catch { }
3432
  }
3433
  if (any) {
3434
- appendRoomMessage({ text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() });
3435
  }
3436
  return any;
3437
  }
@@ -3514,7 +3630,7 @@ let supabase;
3514
  let msg;
3515
  try { msg = JSON.parse(e.data); } catch { return; }
3516
  if (msg?.type === 'chat.message') {
3517
- appendRoomMessage({ text: msg.text, ts: msg.ts, fromDeviceId: peerId });
3518
  }
3519
  };
3520
  dc.onclose = () => {
 
197
  const admin = document.getElementById('admin-panel');
198
  if (admin) admin.scrollIntoView({ block: 'nearest' });
199
  // Best-effort auto-load templates for admins.
200
+ setTimeout(() => {
201
+ loadAdminFeatureOverrides();
202
+ loadAdminMcpTemplates();
203
+ }, 0);
204
  }
205
 
206
  function closeSettings() {
 
770
  }
771
  }
772
 
773
+ function setAdminOverridesStatus(msg) {
774
+ const el = document.getElementById('admin-overrides-status');
775
+ if (el) el.textContent = msg || '';
776
+ }
777
+
778
+ function getAdminOverrideInputs() {
779
+ return {
780
+ terminal: document.getElementById('admin-override-terminal'),
781
+ codex: document.getElementById('admin-override-codex'),
782
+ mcp: document.getElementById('admin-override-mcp'),
783
+ indexing: document.getElementById('admin-override-indexing'),
784
+ rooms: document.getElementById('admin-override-rooms'),
785
+ };
786
+ }
787
+
788
+ async function loadAdminFeatureOverrides() {
789
+ const inputs = getAdminOverrideInputs();
790
+ if (!inputs.terminal) return;
791
+ try {
792
+ setAdminOverridesStatus('Loading…');
793
+ const res = await authFetch('/api/admin/features');
794
+ if (!res.ok) throw new Error(await res.text());
795
+ const data = await res.json();
796
+ const overrides = data?.overrides || {};
797
+ for (const key of Object.keys(inputs)) {
798
+ const el = inputs[key];
799
+ if (!el) continue;
800
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) {
801
+ el.checked = !!overrides[key];
802
+ } else {
803
+ // If no override is set, fall back to effective state as the default UI value.
804
+ el.checked = !!data?.features?.[key]?.enabled;
805
+ }
806
+ }
807
+ setAdminOverridesStatus('Loaded.');
808
+ } catch (e) {
809
+ const msg = String(e?.message || e);
810
+ if (msg.includes('403')) return;
811
+ setAdminOverridesStatus(`Load failed: ${msg}`);
812
+ }
813
+ }
814
+
815
+ async function saveAdminFeatureOverrides() {
816
+ const inputs = getAdminOverrideInputs();
817
+ if (!inputs.terminal) return;
818
+ const overrides = {};
819
+ for (const key of Object.keys(inputs)) {
820
+ const el = inputs[key];
821
+ if (!el) continue;
822
+ overrides[key] = !!el.checked;
823
+ }
824
+ try {
825
+ setAdminOverridesStatus('Saving…');
826
+ const res = await authFetch('/api/admin/features', {
827
+ method: 'PUT',
828
+ headers: { 'Content-Type': 'application/json' },
829
+ body: JSON.stringify({ overrides }),
830
+ });
831
+ if (!res.ok) throw new Error(await res.text());
832
+ await res.json();
833
+ setAdminOverridesStatus('Saved.');
834
+ } catch (e) {
835
+ setAdminOverridesStatus(`Save failed: ${e?.message || e}`);
836
+ }
837
+ }
838
+
839
  async function refreshCodexMcpServers() {
840
  const out = document.getElementById('codex-mcp-list');
841
  if (out) out.textContent = 'Loading...';
 
3163
  let roomsActiveRoomId = null;
3164
  let roomsPeers = [];
3165
  let roomsP2P = new Map(); // deviceId -> { pc, dc, polite, makingOffer, ignoreOffer }
3166
+ let roomsMessageEls = new Map(); // messageId -> { el, statusEl }
3167
+ let roomsReconnectAttempts = 0;
3168
+ let roomsReconnectTimer = null;
3169
+ let roomsWsClosing = false;
3170
 
3171
  function getDeviceId() {
3172
  const key = 'device_id_v1';
 
3215
  box.appendChild(div);
3216
  }
3217
 
3218
+ function appendRoomMessage(msg, { status = '' } = {}) {
3219
  const box = document.getElementById('rooms-messages');
3220
  if (!box) return;
3221
+ const msgId = String(msg?.id || '').trim();
3222
  const fromDevice = String(msg?.fromDeviceId || '').trim();
3223
  const isMe = fromDevice && fromDevice === getDeviceId();
3224
 
 
3230
  ? 'bg-blue-600/20 border-blue-500/30 text-gray-100'
3231
  : 'bg-gray-800/60 border-gray-700 text-gray-100'
3232
  }`;
3233
+ if (msgId) bubble.dataset.messageId = msgId;
3234
 
3235
  const meta = document.createElement('div');
3236
  meta.className = 'text-[11px] text-gray-400 mb-1 flex items-center justify-between gap-3';
3237
  const who = document.createElement('span');
3238
  who.textContent = isMe ? 'you' : (fromDevice ? fromDevice.slice(0, 8) : 'peer');
3239
+ const right = document.createElement('span');
3240
  const ts = document.createElement('span');
3241
  ts.textContent = String(msg?.ts || '');
3242
+ const st = document.createElement('span');
3243
+ st.className = 'ml-2 text-gray-500';
3244
+ st.textContent = status ? String(status) : '';
3245
+ right.appendChild(ts);
3246
+ right.appendChild(st);
3247
  meta.appendChild(who);
3248
+ meta.appendChild(right);
3249
 
3250
  const text = document.createElement('div');
3251
  text.textContent = String(msg?.text || '');
 
3255
  wrap.appendChild(bubble);
3256
  box.appendChild(wrap);
3257
  box.scrollTop = box.scrollHeight;
3258
+ if (msgId) roomsMessageEls.set(msgId, { el: bubble, statusEl: st });
3259
+ return bubble;
3260
  }
3261
 
3262
  function renderRoomsList(rooms) {
 
3387
 
3388
  async function setActiveRoom(roomId, name) {
3389
  roomsActiveRoomId = String(roomId || '').trim();
3390
+ roomsMessageEls = new Map();
3391
  updateRoomsHeader(roomsActiveRoomId, name || 'Room');
3392
  renderRoomsMessagesEmpty('Loading…');
3393
  await loadRoomsList();
 
3417
  }
3418
 
3419
  function disconnectRoomsWs() {
3420
+ roomsWsClosing = true;
3421
  if (roomsWs) {
3422
  try { roomsWs.close(); } catch { }
3423
  roomsWs = null;
3424
  }
3425
+ if (roomsReconnectTimer) {
3426
+ clearTimeout(roomsReconnectTimer);
3427
+ roomsReconnectTimer = null;
3428
+ }
3429
  setRoomsConnStatus('Disconnected');
3430
+ setTimeout(() => { roomsWsClosing = false; }, 250);
3431
+ }
3432
+
3433
+ function scheduleRoomsReconnect() {
3434
+ if (roomsReconnectTimer) return;
3435
+ if (!roomsActiveRoomId) return;
3436
+ roomsReconnectAttempts += 1;
3437
+ if (roomsReconnectAttempts > 5) return;
3438
+ const delay = Math.min(8000, 600 * roomsReconnectAttempts);
3439
+ roomsReconnectTimer = setTimeout(() => {
3440
+ roomsReconnectTimer = null;
3441
+ connectRoomsWs().catch(() => { });
3442
+ }, delay);
3443
+ setRoomsConnStatus(`Reconnecting… (${roomsReconnectAttempts})`);
3444
  }
3445
 
3446
  async function connectRoomsWs() {
 
3462
  setRoomsConnStatus('Connecting…');
3463
 
3464
  roomsWs.onopen = () => {
3465
+ roomsReconnectAttempts = 0;
3466
  setRoomsConnStatus('WS connected');
3467
  maybeStartRoomP2p();
3468
  };
3469
  roomsWs.onclose = () => {
3470
  setRoomsConnStatus('Disconnected');
3471
  closeAllRoomP2p();
3472
+ if (!roomsWsClosing) scheduleRoomsReconnect();
3473
  };
3474
  roomsWs.onmessage = (event) => {
3475
  let msg;
 
3497
  return;
3498
  }
3499
  if (t === 'chat.message') {
3500
+ const msgId = String(msg?.id || '').trim();
3501
+ const fromDevice = String(msg?.fromDeviceId || '').trim();
3502
+ const isMe = fromDevice && fromDevice === getDeviceId();
3503
+ if (msgId && isMe && roomsMessageEls.has(msgId)) {
3504
+ const entry = roomsMessageEls.get(msgId);
3505
+ if (entry?.statusEl) entry.statusEl.textContent = 'delivered';
3506
+ return;
3507
+ }
3508
  appendRoomMessage(msg);
3509
  return;
3510
  }
 
3531
  const sent = sendRoomMessageViaP2p(text);
3532
  if (sent) return;
3533
  }
3534
+ const clientId = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())) + '-' + Math.random().toString(16).slice(2);
3535
+ appendRoomMessage({ id: clientId, text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() }, { status: 'sending' });
3536
+ sendRoomsWs({ type: 'chat.send', text, clientId });
3537
  }
3538
 
3539
  function sendRoomMessageViaP2p(text) {
3540
+ const clientId = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())) + '-' + Math.random().toString(16).slice(2);
3541
  let any = false;
3542
  for (const entry of roomsP2P.values()) {
3543
  if (!entry?.dc || entry.dc.readyState !== 'open') continue;
3544
  try {
3545
+ entry.dc.send(JSON.stringify({ type: 'chat.message', id: clientId, text, ts: new Date().toISOString() }));
3546
  any = true;
3547
  } catch { }
3548
  }
3549
  if (any) {
3550
+ appendRoomMessage({ id: clientId, text, ts: new Date().toISOString(), fromDeviceId: getDeviceId() }, { status: 'p2p' });
3551
  }
3552
  return any;
3553
  }
 
3630
  let msg;
3631
  try { msg = JSON.parse(e.data); } catch { return; }
3632
  if (msg?.type === 'chat.message') {
3633
+ appendRoomMessage({ id: msg.id, text: msg.text, ts: msg.ts, fromDeviceId: peerId });
3634
  }
3635
  };
3636
  dc.onclose = () => {