multi-agent-collaboration-mcp 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -281
- package/dist/bounded-lines.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/check.js +131 -44
- package/dist/db.js +935 -583
- package/dist/index.js +571 -575
- package/dist/poller.js +179 -78
- package/package.json +3 -2
- package/scripts/connection-probe.mjs +220 -0
- package/web/index.html +783 -256
- package/web/server.mjs +377 -159
package/web/index.html
CHANGED
|
@@ -1205,13 +1205,14 @@
|
|
|
1205
1205
|
id="join-name"
|
|
1206
1206
|
placeholder="Your name"
|
|
1207
1207
|
aria-label="Your name"
|
|
1208
|
-
maxlength="
|
|
1208
|
+
maxlength="177"
|
|
1209
1209
|
autocomplete="off"
|
|
1210
1210
|
spellcheck="false"
|
|
1211
1211
|
/>
|
|
1212
1212
|
<button id="join-btn" class="btn-accent">Join room</button>
|
|
1213
|
+
<button id="join-change-btn" class="linkish" hidden>Change name</button>
|
|
1213
1214
|
<span class="compose-hint"
|
|
1214
|
-
>no password: identity is self-asserted
|
|
1215
|
+
>no password: human identity is self-asserted</span
|
|
1215
1216
|
>
|
|
1216
1217
|
<span id="join-error" class="compose-hint" style="color: var(--danger)"></span>
|
|
1217
1218
|
</div>
|
|
@@ -1242,8 +1243,13 @@
|
|
|
1242
1243
|
<script>
|
|
1243
1244
|
"use strict";
|
|
1244
1245
|
|
|
1245
|
-
const ROOM_KEY = "agent-chat.room";
|
|
1246
|
-
const SEEN_KEY = "agent-chat.seen";
|
|
1246
|
+
const ROOM_KEY = "agent-chat.v2.room";
|
|
1247
|
+
const SEEN_KEY = "agent-chat.v2.seen";
|
|
1248
|
+
const SEEN_LOCK = "agent-chat.seen";
|
|
1249
|
+
const IDENT_KEY = "agent-chat.v2.identity";
|
|
1250
|
+
const JOINED_KEY = "agent-chat.v2.joined";
|
|
1251
|
+
const GHOST_KEY = "agent-chat.v2.ghosts";
|
|
1252
|
+
const IDENTITY_VERSION = 2;
|
|
1247
1253
|
const POLL_MS = 1500;
|
|
1248
1254
|
const ROOMS_MS = 30000;
|
|
1249
1255
|
const PAGE = 400;
|
|
@@ -1258,7 +1264,7 @@
|
|
|
1258
1264
|
"room-head", "room-name", "room-desc", "room-stats",
|
|
1259
1265
|
"pinned", "pinned-preview", "pinned-body",
|
|
1260
1266
|
"banner", "stream-wrap", "stream", "older", "markall", "msgs", "jump", "placeholder",
|
|
1261
|
-
"composer", "compose-join", "join-name", "join-btn", "join-error",
|
|
1267
|
+
"composer", "compose-join", "join-name", "join-btn", "join-change-btn", "join-error",
|
|
1262
1268
|
"compose-box", "reply-target", "compose-text", "send", "compose-ident",
|
|
1263
1269
|
"ident-btn", "leave-btn", "compose-error",
|
|
1264
1270
|
"search", "search-bar", "search-info", "search-back", "results",
|
|
@@ -1276,7 +1282,8 @@
|
|
|
1276
1282
|
// polling cannot walk and rebuild every intervening 400-row page.
|
|
1277
1283
|
historicalWindow: false,
|
|
1278
1284
|
failures: 0,
|
|
1279
|
-
|
|
1285
|
+
// Truthy for the full lifetime of a message load and awaitable by jumps.
|
|
1286
|
+
msgsLoad: null,
|
|
1280
1287
|
loadingRooms: false,
|
|
1281
1288
|
seen: loadSeen(),
|
|
1282
1289
|
replyTo: null, // { seq, label } the composer is replying to
|
|
@@ -1316,16 +1323,14 @@
|
|
|
1316
1323
|
roomEpoch: 0,
|
|
1317
1324
|
searchSeq: 0, // orders concurrent search requests
|
|
1318
1325
|
searchController: null, // aborts the superseded/abandoned search request
|
|
1326
|
+
changingIdentity: false,
|
|
1327
|
+
changingFrom: null,
|
|
1319
1328
|
// A rename that lands WHILE search mode hides the stream cannot rebuild
|
|
1320
1329
|
// the (hidden) rows' identity-derived decoration then; this defers it
|
|
1321
1330
|
// to exitSearch.
|
|
1322
1331
|
needsRebuild: false,
|
|
1323
1332
|
};
|
|
1324
1333
|
|
|
1325
|
-
const IDENT_KEY = "agent-chat.identity";
|
|
1326
|
-
const JOINED_KEY = "agent-chat.joined";
|
|
1327
|
-
const GHOST_KEY = "agent-chat.ghosts";
|
|
1328
|
-
|
|
1329
1334
|
// Cross-tab serialization for EVERY membership mutation (identity,
|
|
1330
1335
|
// joined map, ghost ledger, and the server calls they mirror). These
|
|
1331
1336
|
// are read-modify-write sequences over shared localStorage: two tabs
|
|
@@ -1342,20 +1347,101 @@
|
|
|
1342
1347
|
return navigator.locks.request("agent-chat.membership", fn);
|
|
1343
1348
|
}
|
|
1344
1349
|
|
|
1350
|
+
function emptyIdentityState() {
|
|
1351
|
+
return {
|
|
1352
|
+
version: IDENTITY_VERSION,
|
|
1353
|
+
current: null,
|
|
1354
|
+
pending_allocation: null,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function readIdentityState() {
|
|
1359
|
+
try {
|
|
1360
|
+
const v = JSON.parse(localStorage.getItem(IDENT_KEY));
|
|
1361
|
+
const currentOk =
|
|
1362
|
+
v?.current === null ||
|
|
1363
|
+
(v?.current &&
|
|
1364
|
+
typeof v.current.base_name === "string" &&
|
|
1365
|
+
typeof v.current.agent_id === "string");
|
|
1366
|
+
const p = v?.pending_allocation;
|
|
1367
|
+
const pendingOk =
|
|
1368
|
+
p === null ||
|
|
1369
|
+
(p &&
|
|
1370
|
+
typeof p.operation_id === "string" &&
|
|
1371
|
+
Number.isSafeInteger(p.room) &&
|
|
1372
|
+
p.room > 0 &&
|
|
1373
|
+
typeof p.base_name === "string" &&
|
|
1374
|
+
(p.previous_agent_id === null ||
|
|
1375
|
+
typeof p.previous_agent_id === "string") &&
|
|
1376
|
+
Array.isArray(p.previous_memberships) &&
|
|
1377
|
+
p.previous_memberships.every(
|
|
1378
|
+
(m) =>
|
|
1379
|
+
m &&
|
|
1380
|
+
Number.isSafeInteger(m.room) &&
|
|
1381
|
+
m.room > 0 &&
|
|
1382
|
+
typeof m.agent_id === "string",
|
|
1383
|
+
));
|
|
1384
|
+
if (
|
|
1385
|
+
v &&
|
|
1386
|
+
v.version === IDENTITY_VERSION &&
|
|
1387
|
+
currentOk &&
|
|
1388
|
+
pendingOk
|
|
1389
|
+
) {
|
|
1390
|
+
return v;
|
|
1391
|
+
}
|
|
1392
|
+
} catch {}
|
|
1393
|
+
return emptyIdentityState();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
let browserIdentity = readIdentityState();
|
|
1397
|
+
|
|
1398
|
+
function reloadIdentityState() {
|
|
1399
|
+
browserIdentity = readIdentityState();
|
|
1400
|
+
return browserIdentity;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// The current identity and its pending replacement share one atomic
|
|
1404
|
+
// localStorage value. Update the in-memory view only after persistence
|
|
1405
|
+
// succeeds, so a thrown write cannot make this tab outrun reload.
|
|
1406
|
+
function saveIdentityState(next) {
|
|
1407
|
+
try {
|
|
1408
|
+
localStorage.setItem(IDENT_KEY, JSON.stringify(next));
|
|
1409
|
+
browserIdentity = next;
|
|
1410
|
+
return true;
|
|
1411
|
+
} catch {
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function currentIdentity() {
|
|
1417
|
+
return browserIdentity.current;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function identity() {
|
|
1421
|
+
return currentIdentity()?.agent_id || "";
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1345
1424
|
// Memberships whose soft-leave failed (e.g. server briefly down during
|
|
1346
1425
|
// a rename): tracked so later joins can retry the leave instead of the
|
|
1347
1426
|
// ghost becoming permanently unaddressable.
|
|
1348
1427
|
function loadGhosts() {
|
|
1349
|
-
//
|
|
1350
|
-
// bare number, an object) written by devtools or version skew would
|
|
1351
|
-
// otherwise throw deep inside consumers as an unhandled rejection.
|
|
1428
|
+
// Reject valid JSON with the wrong shape before consumers assume an array.
|
|
1352
1429
|
try {
|
|
1353
1430
|
const v = JSON.parse(localStorage.getItem(GHOST_KEY));
|
|
1354
1431
|
return Array.isArray(v)
|
|
1355
|
-
? v
|
|
1356
|
-
(
|
|
1357
|
-
g
|
|
1358
|
-
|
|
1432
|
+
? v
|
|
1433
|
+
.filter(
|
|
1434
|
+
(g) =>
|
|
1435
|
+
g &&
|
|
1436
|
+
Number.isSafeInteger(g.room) &&
|
|
1437
|
+
g.room > 0 &&
|
|
1438
|
+
typeof g.agent_id === "string",
|
|
1439
|
+
)
|
|
1440
|
+
.map((g) => ({
|
|
1441
|
+
room: g.room,
|
|
1442
|
+
agent_id: g.agent_id,
|
|
1443
|
+
explicit_leave: g.explicit_leave === true,
|
|
1444
|
+
}))
|
|
1359
1445
|
: [];
|
|
1360
1446
|
} catch {
|
|
1361
1447
|
return [];
|
|
@@ -1364,53 +1450,101 @@
|
|
|
1364
1450
|
function saveGhosts(g) {
|
|
1365
1451
|
try {
|
|
1366
1452
|
localStorage.setItem(GHOST_KEY, JSON.stringify(g));
|
|
1367
|
-
|
|
1453
|
+
return true;
|
|
1454
|
+
} catch {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Always filter the current ledger: callers may have awaited network
|
|
1459
|
+
// work since they last loaded it, while another tab added an entry.
|
|
1460
|
+
function removeGhost(room, agentId) {
|
|
1461
|
+
return saveGhosts(
|
|
1462
|
+
loadGhosts().filter(
|
|
1463
|
+
(g) => !(g.room === room && g.agent_id === agentId),
|
|
1464
|
+
),
|
|
1465
|
+
);
|
|
1368
1466
|
}
|
|
1369
1467
|
let ghostRetryActive = false;
|
|
1370
1468
|
async function retryGhosts() {
|
|
1371
1469
|
if (ghostRetryActive) return; // overlapping sweeps would race the ledger
|
|
1372
1470
|
ghostRetryActive = true;
|
|
1471
|
+
let currentMembershipChanged = false;
|
|
1373
1472
|
try {
|
|
1374
1473
|
// The membership lock is held ACROSS each leave's round trip: the
|
|
1375
1474
|
// stale-check below is only race-free if no other tab can re-adopt
|
|
1376
|
-
// the (room,
|
|
1475
|
+
// the (room, agent id) while the leave is in flight.
|
|
1377
1476
|
await withMembershipLock(async () => {
|
|
1477
|
+
reloadIdentityState();
|
|
1478
|
+
// Pending allocation recovery must establish the new current
|
|
1479
|
+
// identity and its cleanup ledger before any old leave can run.
|
|
1480
|
+
if (browserIdentity.pending_allocation) return;
|
|
1378
1481
|
for (const g of loadGhosts()) {
|
|
1379
|
-
// A ghost whose (room,
|
|
1380
|
-
// stale: the user re-adopted the
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
);
|
|
1482
|
+
// A cleanup ghost whose (room, agent id) is the CURRENT mapping
|
|
1483
|
+
// again is not stale: the user re-adopted the identity and
|
|
1484
|
+
// rejoined. An explicit-leave ghost is different: the matching
|
|
1485
|
+
// map can be the crash point between recording the leave intent
|
|
1486
|
+
// and deleting that map entry, so it must still be processed.
|
|
1487
|
+
if (
|
|
1488
|
+
!g.explicit_leave &&
|
|
1489
|
+
loadJoined()[String(g.room)] === g.agent_id
|
|
1490
|
+
) {
|
|
1491
|
+
if (!removeGhost(g.room, g.agent_id)) return;
|
|
1389
1492
|
continue;
|
|
1390
1493
|
}
|
|
1391
1494
|
try {
|
|
1392
|
-
await postJson("/api/leave", {
|
|
1393
|
-
|
|
1394
|
-
|
|
1495
|
+
await postJson("/api/leave", {
|
|
1496
|
+
room: g.room,
|
|
1497
|
+
agent_id: g.agent_id,
|
|
1498
|
+
});
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
// Generated fields cannot become valid through retry. A 400
|
|
1501
|
+
// means the room or human no longer exists, or stored state is
|
|
1502
|
+
// malformed, so finish local cleanup instead of keeping an
|
|
1503
|
+
// immortal request. Transport and server failures remain
|
|
1504
|
+
// retryable.
|
|
1505
|
+
if (error?.httpStatus !== 400) continue;
|
|
1395
1506
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
(
|
|
1402
|
-
|
|
1403
|
-
|
|
1507
|
+
if (g.explicit_leave) {
|
|
1508
|
+
const joined = loadJoined();
|
|
1509
|
+
const key = String(g.room);
|
|
1510
|
+
if (joined[key] === g.agent_id) {
|
|
1511
|
+
delete joined[key];
|
|
1512
|
+
if (!saveJoined(joined)) {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (
|
|
1516
|
+
state.current?.id === g.room &&
|
|
1517
|
+
identity() === g.agent_id
|
|
1518
|
+
) {
|
|
1519
|
+
currentMembershipChanged = true;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (!removeGhost(g.room, g.agent_id)) return;
|
|
1404
1524
|
}
|
|
1405
1525
|
});
|
|
1526
|
+
// localStorage notifies only OTHER tabs. When this tab recovered the
|
|
1527
|
+
// leave, refresh its own UI and invalidate work captured while the
|
|
1528
|
+
// membership still appeared present.
|
|
1529
|
+
if (
|
|
1530
|
+
currentMembershipChanged &&
|
|
1531
|
+
state.current &&
|
|
1532
|
+
!joinedHere()
|
|
1533
|
+
) {
|
|
1534
|
+
state.roomEpoch += 1;
|
|
1535
|
+
state.lastReadSent = 0;
|
|
1536
|
+
state.marker = -1;
|
|
1537
|
+
state.markerIdent = null;
|
|
1538
|
+
state.gap = false;
|
|
1539
|
+
clearReply();
|
|
1540
|
+
renderComposer();
|
|
1541
|
+
computeGap();
|
|
1542
|
+
}
|
|
1406
1543
|
} finally {
|
|
1407
1544
|
ghostRetryActive = false;
|
|
1408
1545
|
}
|
|
1409
1546
|
}
|
|
1410
1547
|
|
|
1411
|
-
function identity() {
|
|
1412
|
-
return localStorage.getItem(IDENT_KEY) || "";
|
|
1413
|
-
}
|
|
1414
1548
|
function loadJoined() {
|
|
1415
1549
|
try {
|
|
1416
1550
|
const v = JSON.parse(localStorage.getItem(JOINED_KEY));
|
|
@@ -1457,7 +1591,11 @@
|
|
|
1457
1591
|
try {
|
|
1458
1592
|
const r = await fetch(url, { ...options, signal: controller.signal });
|
|
1459
1593
|
const data = await r.json().catch(() => ({}));
|
|
1460
|
-
if (!r.ok)
|
|
1594
|
+
if (!r.ok) {
|
|
1595
|
+
const error = new Error(data.error || "HTTP " + r.status);
|
|
1596
|
+
error.httpStatus = r.status;
|
|
1597
|
+
throw error;
|
|
1598
|
+
}
|
|
1461
1599
|
return data;
|
|
1462
1600
|
} finally {
|
|
1463
1601
|
clearTimeout(timeout);
|
|
@@ -1474,18 +1612,68 @@
|
|
|
1474
1612
|
|
|
1475
1613
|
// ---- utilities ------------------------------------------------------
|
|
1476
1614
|
|
|
1615
|
+
function parseSeen(raw) {
|
|
1616
|
+
try {
|
|
1617
|
+
const value = JSON.parse(raw);
|
|
1618
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1619
|
+
return {};
|
|
1620
|
+
}
|
|
1621
|
+
const out = {};
|
|
1622
|
+
for (const [room, seq] of Object.entries(value)) {
|
|
1623
|
+
const roomId = Number(room);
|
|
1624
|
+
if (
|
|
1625
|
+
Number.isSafeInteger(roomId) &&
|
|
1626
|
+
roomId > 0 &&
|
|
1627
|
+
String(roomId) === room &&
|
|
1628
|
+
Number.isSafeInteger(seq) &&
|
|
1629
|
+
seq > 0
|
|
1630
|
+
) {
|
|
1631
|
+
out[roomId] = seq;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return out;
|
|
1635
|
+
} catch {
|
|
1636
|
+
return {};
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1477
1640
|
function loadSeen() {
|
|
1478
1641
|
try {
|
|
1479
|
-
|
|
1480
|
-
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1642
|
+
return parseSeen(localStorage.getItem(SEEN_KEY));
|
|
1481
1643
|
} catch {
|
|
1482
1644
|
return {};
|
|
1483
1645
|
}
|
|
1484
1646
|
}
|
|
1485
|
-
|
|
1647
|
+
|
|
1648
|
+
function mergeSeen(target, roomId, seq) {
|
|
1649
|
+
if (seq <= (target[roomId] ?? 0)) return false;
|
|
1650
|
+
target[roomId] = seq;
|
|
1651
|
+
return true;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function persistSeen(seen) {
|
|
1486
1655
|
try {
|
|
1487
|
-
localStorage.setItem(SEEN_KEY, JSON.stringify(
|
|
1488
|
-
} catch {
|
|
1656
|
+
localStorage.setItem(SEEN_KEY, JSON.stringify(seen));
|
|
1657
|
+
} catch {
|
|
1658
|
+
// This tab stays optimistic; another tab or reload may show the dot.
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
async function saveSeen(roomId, seq) {
|
|
1663
|
+
await navigator.locks.request(SEEN_LOCK, () => {
|
|
1664
|
+
const seen = loadSeen();
|
|
1665
|
+
if (!mergeSeen(seen, roomId, seq)) return;
|
|
1666
|
+
persistSeen(seen);
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
async function forgetSeen(roomId) {
|
|
1671
|
+
delete state.seen[roomId];
|
|
1672
|
+
await navigator.locks.request(SEEN_LOCK, () => {
|
|
1673
|
+
const seen = loadSeen();
|
|
1674
|
+
delete seen[roomId];
|
|
1675
|
+
persistSeen(seen);
|
|
1676
|
+
});
|
|
1489
1677
|
}
|
|
1490
1678
|
|
|
1491
1679
|
// Stable per-agent identity in GRAYSCALE: the FNV-1a hash picks a
|
|
@@ -1514,10 +1702,21 @@
|
|
|
1514
1702
|
return new Date(at.replace(" ", "T") + "Z");
|
|
1515
1703
|
}
|
|
1516
1704
|
|
|
1705
|
+
const TIME_FORMATTER = new Intl.DateTimeFormat([], {
|
|
1706
|
+
hour: "2-digit",
|
|
1707
|
+
minute: "2-digit",
|
|
1708
|
+
second: "2-digit",
|
|
1709
|
+
hour12: false,
|
|
1710
|
+
});
|
|
1711
|
+
const DAY_FORMATTER = new Intl.DateTimeFormat([], {
|
|
1712
|
+
weekday: "short",
|
|
1713
|
+
year: "numeric",
|
|
1714
|
+
month: "short",
|
|
1715
|
+
day: "numeric",
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1517
1718
|
function fmtTime(d) {
|
|
1518
|
-
return
|
|
1519
|
-
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
|
1520
|
-
});
|
|
1719
|
+
return TIME_FORMATTER.format(d);
|
|
1521
1720
|
}
|
|
1522
1721
|
|
|
1523
1722
|
function fmtDay(d) {
|
|
@@ -1527,9 +1726,7 @@
|
|
|
1527
1726
|
const days = Math.round((today - that) / 86400000);
|
|
1528
1727
|
if (days === 0) return "Today";
|
|
1529
1728
|
if (days === 1) return "Yesterday";
|
|
1530
|
-
return
|
|
1531
|
-
weekday: "short", year: "numeric", month: "short", day: "numeric",
|
|
1532
|
-
});
|
|
1729
|
+
return DAY_FORMATTER.format(d);
|
|
1533
1730
|
}
|
|
1534
1731
|
|
|
1535
1732
|
function fmtRel(at) {
|
|
@@ -1678,12 +1875,11 @@
|
|
|
1678
1875
|
card.append(desc);
|
|
1679
1876
|
}
|
|
1680
1877
|
|
|
1878
|
+
// Use seq because timestamps collide within one second. Compare by
|
|
1879
|
+
// ordering because the rendered tail can be ahead of the cached list.
|
|
1681
1880
|
const isOpen = state.current && state.current.id === room.id;
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
room.last_activity &&
|
|
1685
|
-
state.seen[room.id] !== room.last_activity
|
|
1686
|
-
) {
|
|
1881
|
+
const seenSeq = state.seen[room.id];
|
|
1882
|
+
if (!isOpen && room.last_seq && !(seenSeq >= room.last_seq)) {
|
|
1687
1883
|
const dot = document.createElement("span");
|
|
1688
1884
|
dot.className = "activity";
|
|
1689
1885
|
dot.title = "new activity since you last viewed this room";
|
|
@@ -1709,7 +1905,9 @@
|
|
|
1709
1905
|
} catch {
|
|
1710
1906
|
return; // server unreachable: keep the room, retry next refresh
|
|
1711
1907
|
}
|
|
1712
|
-
if (!gone
|
|
1908
|
+
if (!gone) return;
|
|
1909
|
+
await forgetDeletedRoom(room.id);
|
|
1910
|
+
if (!sameView(room, epoch)) return;
|
|
1713
1911
|
state.current = null;
|
|
1714
1912
|
state.searchMode = false;
|
|
1715
1913
|
el.streamWrap.hidden = true;
|
|
@@ -1748,8 +1946,7 @@
|
|
|
1748
1946
|
pinned,
|
|
1749
1947
|
description: fullDesc ?? cur.description,
|
|
1750
1948
|
};
|
|
1751
|
-
|
|
1752
|
-
saveSeen();
|
|
1949
|
+
// Refreshing metadata does not prove the stream was rendered.
|
|
1753
1950
|
renderRoomHead();
|
|
1754
1951
|
} else {
|
|
1755
1952
|
// The open room is ABSENT from the (capped, most-recently-active)
|
|
@@ -1766,7 +1963,7 @@
|
|
|
1766
1963
|
// Recovered from "Loading..."/error with no room selected.
|
|
1767
1964
|
showPlaceholder(
|
|
1768
1965
|
"Pick a room.",
|
|
1769
|
-
"Rooms are listed on the left; the
|
|
1966
|
+
"Rooms are listed on the left; the activity dot marks messages you have not seen.",
|
|
1770
1967
|
);
|
|
1771
1968
|
}
|
|
1772
1969
|
renderRooms();
|
|
@@ -1903,27 +2100,29 @@
|
|
|
1903
2100
|
container.append(wrap);
|
|
1904
2101
|
}
|
|
1905
2102
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
// Scroll a message into view. If it is outside the bounded DOM window,
|
|
1909
|
-
// fetch a page starting at that seq directly; never walk/rebuild
|
|
1910
|
-
// thousands of intermediate pages.
|
|
2103
|
+
// Fetch one anchored page for an off-window target, never intermediate
|
|
2104
|
+
// pages. Leave the current view alone when the target was pruned.
|
|
1911
2105
|
async function jumpToSeq(seq) {
|
|
1912
2106
|
const room = state.current;
|
|
1913
2107
|
const epoch = state.roomEpoch;
|
|
1914
2108
|
if (!room || !Number.isSafeInteger(seq) || seq < 1) return;
|
|
1915
2109
|
let target = el.msgs.querySelector(`[data-seq="${seq}"]`);
|
|
1916
2110
|
if (!target) {
|
|
1917
|
-
//
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2111
|
+
// Wait for the real load lifetime, not an unrelated fixed delay.
|
|
2112
|
+
while (state.msgsLoad) await state.msgsLoad;
|
|
2113
|
+
if (!sameView(room, epoch)) return;
|
|
2114
|
+
target = el.msgs.querySelector(`[data-seq="${seq}"]`);
|
|
2115
|
+
}
|
|
2116
|
+
if (!target) {
|
|
2117
|
+
let releaseLoad;
|
|
2118
|
+
state.msgsLoad = new Promise((r) => (releaseLoad = r));
|
|
1923
2119
|
try {
|
|
1924
2120
|
const anchor = seq === 1 ? "before=2&limit=1" : `after=${seq - 1}&limit=${PAGE}`;
|
|
1925
2121
|
const data = await fetchJson(`/api/messages?room=${room.id}&${anchor}`);
|
|
1926
2122
|
if (!sameView(room, epoch) || data.error || !data.messages) return;
|
|
2123
|
+
// Both anchors put an existing target at row zero. Check before
|
|
2124
|
+
// freezing or replacing the live stream because targets can prune.
|
|
2125
|
+
if (data.messages[0]?.seq !== seq) return;
|
|
1927
2126
|
state.msgs = data.messages;
|
|
1928
2127
|
state.historicalWindow = true;
|
|
1929
2128
|
state.follow = false;
|
|
@@ -1938,12 +2137,12 @@
|
|
|
1938
2137
|
noteFail();
|
|
1939
2138
|
return;
|
|
1940
2139
|
} finally {
|
|
1941
|
-
state.
|
|
2140
|
+
state.msgsLoad = null;
|
|
2141
|
+
releaseLoad();
|
|
1942
2142
|
}
|
|
1943
2143
|
target = el.msgs.querySelector(`[data-seq="${seq}"]`);
|
|
1944
2144
|
}
|
|
1945
|
-
if (!sameView(room, epoch)) return;
|
|
1946
|
-
if (!target) return;
|
|
2145
|
+
if (!sameView(room, epoch) || !target) return;
|
|
1947
2146
|
target.scrollIntoView({ block: "center" });
|
|
1948
2147
|
target.classList.remove("flash");
|
|
1949
2148
|
void target.offsetWidth; // restart the animation
|
|
@@ -1986,11 +2185,13 @@
|
|
|
1986
2185
|
const who = document.createElement("span");
|
|
1987
2186
|
who.className = "who";
|
|
1988
2187
|
who.textContent = label(m);
|
|
2188
|
+
who.title = m.from;
|
|
1989
2189
|
head.append(who);
|
|
1990
2190
|
if (label(m) !== m.from) {
|
|
1991
2191
|
const id = document.createElement("span");
|
|
1992
2192
|
id.className = "who-id";
|
|
1993
2193
|
id.textContent = m.from;
|
|
2194
|
+
id.title = m.from;
|
|
1994
2195
|
head.append(id);
|
|
1995
2196
|
}
|
|
1996
2197
|
if (identity() && m.from === identity()) {
|
|
@@ -2166,7 +2367,7 @@
|
|
|
2166
2367
|
const epoch = state.roomEpoch;
|
|
2167
2368
|
if (!room || state.searchMode) return;
|
|
2168
2369
|
if (state.historicalWindow && !initial) return;
|
|
2169
|
-
if (state.
|
|
2370
|
+
if (state.msgsLoad) {
|
|
2170
2371
|
if (initial) state.pendingInitial = true;
|
|
2171
2372
|
return;
|
|
2172
2373
|
}
|
|
@@ -2174,7 +2375,8 @@
|
|
|
2174
2375
|
initial = true;
|
|
2175
2376
|
state.pendingInitial = false;
|
|
2176
2377
|
}
|
|
2177
|
-
|
|
2378
|
+
let releaseLoad;
|
|
2379
|
+
state.msgsLoad = new Promise((r) => (releaseLoad = r));
|
|
2178
2380
|
try {
|
|
2179
2381
|
const last = state.msgs.length
|
|
2180
2382
|
? state.msgs[state.msgs.length - 1].seq
|
|
@@ -2220,33 +2422,40 @@
|
|
|
2220
2422
|
} else {
|
|
2221
2423
|
hidePlaceholder();
|
|
2222
2424
|
}
|
|
2223
|
-
requestAnimationFrame(
|
|
2425
|
+
requestAnimationFrame(() => {
|
|
2426
|
+
if (!sameView(room, epoch)) return;
|
|
2427
|
+
scrollBottom();
|
|
2428
|
+
noteSeen();
|
|
2429
|
+
maybeMarkRead();
|
|
2430
|
+
});
|
|
2224
2431
|
} else if (rows.length) {
|
|
2225
2432
|
hidePlaceholder();
|
|
2226
2433
|
const stick = state.follow || nearBottom();
|
|
2227
2434
|
appendMsgs(rows, true);
|
|
2228
2435
|
if (stick) {
|
|
2229
|
-
requestAnimationFrame(
|
|
2436
|
+
requestAnimationFrame(() => {
|
|
2437
|
+
if (!sameView(room, epoch)) return;
|
|
2438
|
+
scrollBottom();
|
|
2439
|
+
noteSeen();
|
|
2440
|
+
maybeMarkRead();
|
|
2441
|
+
});
|
|
2230
2442
|
} else {
|
|
2231
2443
|
state.newCount += rows.length;
|
|
2232
2444
|
updateJump();
|
|
2233
2445
|
}
|
|
2234
2446
|
}
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
// fetchMarker supplies an in-flight guard and five-second backoff.
|
|
2447
|
+
// Retry marker bootstrap only while it is unknown for this identity;
|
|
2448
|
+
// fetchMarker owns the in-flight guard and five-second backoff.
|
|
2238
2449
|
if (state.marker < 0 || state.markerIdent !== identity()) fetchMarker();
|
|
2239
|
-
maybeMarkRead();
|
|
2240
2450
|
} catch {
|
|
2241
2451
|
if (initial) state.pendingInitial = true; // retry with initial semantics
|
|
2242
2452
|
noteFail();
|
|
2243
2453
|
} finally {
|
|
2244
|
-
state.
|
|
2454
|
+
state.msgsLoad = null;
|
|
2455
|
+
releaseLoad();
|
|
2245
2456
|
}
|
|
2246
2457
|
}
|
|
2247
2458
|
|
|
2248
|
-
// Returns true when it prepended messages (used by jumpToSeq to detect
|
|
2249
|
-
// progress); false on busy, error, or exhausted history.
|
|
2250
2459
|
async function loadOlder() {
|
|
2251
2460
|
const room = state.current;
|
|
2252
2461
|
const epoch = state.roomEpoch;
|
|
@@ -2254,12 +2463,12 @@
|
|
|
2254
2463
|
if (
|
|
2255
2464
|
!room ||
|
|
2256
2465
|
!first ||
|
|
2257
|
-
state.
|
|
2466
|
+
state.msgsLoad ||
|
|
2258
2467
|
state.msgs.length >= MAX_RENDERED_MESSAGES
|
|
2259
|
-
) return
|
|
2260
|
-
|
|
2468
|
+
) return;
|
|
2469
|
+
let releaseLoad;
|
|
2470
|
+
state.msgsLoad = new Promise((r) => (releaseLoad = r));
|
|
2261
2471
|
el.older.disabled = true;
|
|
2262
|
-
let changed = false;
|
|
2263
2472
|
try {
|
|
2264
2473
|
const pageLimit = Math.min(
|
|
2265
2474
|
PAGE,
|
|
@@ -2268,7 +2477,7 @@
|
|
|
2268
2477
|
const data = await fetchJson(
|
|
2269
2478
|
`/api/messages?room=${room.id}&before=${first.seq}&limit=${pageLimit}`,
|
|
2270
2479
|
);
|
|
2271
|
-
if (!sameView(room, epoch) || data.error || !data.messages) return
|
|
2480
|
+
if (!sameView(room, epoch) || data.error || !data.messages) return;
|
|
2272
2481
|
if (data.messages.length) {
|
|
2273
2482
|
const prevHeight = el.stream.scrollHeight;
|
|
2274
2483
|
const prevTop = el.stream.scrollTop;
|
|
@@ -2276,13 +2485,13 @@
|
|
|
2276
2485
|
rebuildStream();
|
|
2277
2486
|
el.stream.scrollTop =
|
|
2278
2487
|
el.stream.scrollHeight - prevHeight + prevTop;
|
|
2279
|
-
changed = true;
|
|
2280
2488
|
}
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
state.noOlderHistory = true;
|
|
2489
|
+
// A short, untrimmed page proves the history floor immediately;
|
|
2490
|
+
// avoid one more request that must return empty.
|
|
2491
|
+
if (data.messages.length < pageLimit && !data.trimmed) {
|
|
2492
|
+
state.noOlderHistory = true;
|
|
2285
2493
|
}
|
|
2494
|
+
updateOlderButton();
|
|
2286
2495
|
const hadGap = state.gap;
|
|
2287
2496
|
computeGap();
|
|
2288
2497
|
if (hadGap && !state.gap) maybeMarkRead(); // gap closed by paging
|
|
@@ -2290,9 +2499,9 @@
|
|
|
2290
2499
|
noteFail();
|
|
2291
2500
|
} finally {
|
|
2292
2501
|
el.older.disabled = false;
|
|
2293
|
-
state.
|
|
2502
|
+
state.msgsLoad = null;
|
|
2503
|
+
releaseLoad();
|
|
2294
2504
|
}
|
|
2295
|
-
return changed;
|
|
2296
2505
|
}
|
|
2297
2506
|
|
|
2298
2507
|
// ---- search mode --------------------------------------------------------
|
|
@@ -2307,11 +2516,13 @@
|
|
|
2307
2516
|
const who = document.createElement("span");
|
|
2308
2517
|
who.className = "who";
|
|
2309
2518
|
who.textContent = label(m);
|
|
2519
|
+
who.title = m.from;
|
|
2310
2520
|
head.append(who);
|
|
2311
2521
|
if (label(m) !== m.from) {
|
|
2312
2522
|
const id = document.createElement("span");
|
|
2313
2523
|
id.className = "who-id";
|
|
2314
2524
|
id.textContent = m.from;
|
|
2525
|
+
id.title = m.from;
|
|
2315
2526
|
head.append(id);
|
|
2316
2527
|
}
|
|
2317
2528
|
const meta = document.createElement("span");
|
|
@@ -2430,19 +2641,159 @@
|
|
|
2430
2641
|
|
|
2431
2642
|
// ---- participation ------------------------------------------------------
|
|
2432
2643
|
|
|
2644
|
+
function recordedMemberships(agentId) {
|
|
2645
|
+
if (!agentId) return [];
|
|
2646
|
+
return Object.entries(loadJoined())
|
|
2647
|
+
.filter(([, storedAgentId]) => storedAgentId === agentId)
|
|
2648
|
+
.map(([room]) => ({ room: Number(room), agent_id: agentId }))
|
|
2649
|
+
.filter((m) => Number.isSafeInteger(m.room) && m.room > 0);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
async function completePendingAllocationLocked(pending) {
|
|
2653
|
+
let j;
|
|
2654
|
+
try {
|
|
2655
|
+
j = await postJson("/api/join", {
|
|
2656
|
+
room: pending.room,
|
|
2657
|
+
base_name: pending.base_name,
|
|
2658
|
+
operation_id: pending.operation_id,
|
|
2659
|
+
});
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
// A delivered 4xx is a final rejection, not an uncertain response:
|
|
2662
|
+
// the server's allocation paths commit nothing on these statuses.
|
|
2663
|
+
// Drop only that request so the user can correct it. Transport
|
|
2664
|
+
// failures and 5xx responses keep the UUID for safe replay.
|
|
2665
|
+
if (
|
|
2666
|
+
error?.httpStatus >= 400 &&
|
|
2667
|
+
error.httpStatus < 500
|
|
2668
|
+
) {
|
|
2669
|
+
saveIdentityState({
|
|
2670
|
+
version: IDENTITY_VERSION,
|
|
2671
|
+
current: browserIdentity.current,
|
|
2672
|
+
pending_allocation: null,
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
throw error;
|
|
2676
|
+
}
|
|
2677
|
+
if (
|
|
2678
|
+
typeof j.agent_id !== "string" ||
|
|
2679
|
+
j.base_name !== pending.base_name ||
|
|
2680
|
+
j.room_id !== pending.room ||
|
|
2681
|
+
typeof j.joined !== "boolean"
|
|
2682
|
+
) {
|
|
2683
|
+
throw new Error("allocation response did not match the pending request");
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
const oldMemberships = pending.previous_memberships.filter(
|
|
2687
|
+
(m) => m.agent_id !== j.agent_id,
|
|
2688
|
+
);
|
|
2689
|
+
if (oldMemberships.length) {
|
|
2690
|
+
const ghosts = loadGhosts();
|
|
2691
|
+
for (const old of oldMemberships) {
|
|
2692
|
+
if (
|
|
2693
|
+
!ghosts.some(
|
|
2694
|
+
(g) =>
|
|
2695
|
+
g.room === old.room && g.agent_id === old.agent_id,
|
|
2696
|
+
)
|
|
2697
|
+
) {
|
|
2698
|
+
ghosts.push(old);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
if (!saveGhosts(ghosts)) {
|
|
2702
|
+
throw new Error(
|
|
2703
|
+
"the new identity was allocated, but cleanup state could not be saved; free browser storage and retry",
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// Save the new membership map before switching the atomic identity
|
|
2709
|
+
// object. If this write fails, pending_allocation remains durable and
|
|
2710
|
+
// reload can replay the same server allocation.
|
|
2711
|
+
const joined = loadJoined();
|
|
2712
|
+
for (const old of oldMemberships) {
|
|
2713
|
+
const key = String(old.room);
|
|
2714
|
+
if (joined[key] === old.agent_id) delete joined[key];
|
|
2715
|
+
}
|
|
2716
|
+
if (j.joined) joined[String(pending.room)] = j.agent_id;
|
|
2717
|
+
if (!saveJoined(joined)) {
|
|
2718
|
+
throw new Error(
|
|
2719
|
+
"the new identity was allocated, but room state could not be saved; free browser storage and retry",
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
const nextIdentity = {
|
|
2724
|
+
version: IDENTITY_VERSION,
|
|
2725
|
+
current: {
|
|
2726
|
+
base_name: j.base_name,
|
|
2727
|
+
agent_id: j.agent_id,
|
|
2728
|
+
},
|
|
2729
|
+
pending_allocation: null,
|
|
2730
|
+
};
|
|
2731
|
+
if (!saveIdentityState(nextIdentity)) {
|
|
2732
|
+
throw new Error(
|
|
2733
|
+
"the new identity was allocated, but identity state could not be saved; free browser storage and retry",
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// Only the durable ghost ledger and switched identity make it safe to
|
|
2738
|
+
// leave old memberships. A failed leave stays in the ledger.
|
|
2739
|
+
for (const old of oldMemberships) {
|
|
2740
|
+
try {
|
|
2741
|
+
await postJson("/api/leave", {
|
|
2742
|
+
room: old.room,
|
|
2743
|
+
agent_id: old.agent_id,
|
|
2744
|
+
});
|
|
2745
|
+
} catch {
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
removeGhost(old.room, old.agent_id);
|
|
2749
|
+
}
|
|
2750
|
+
return j;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
async function recoverPendingAllocationLocked() {
|
|
2754
|
+
const pending = browserIdentity.pending_allocation;
|
|
2755
|
+
return pending
|
|
2756
|
+
? completePendingAllocationLocked(pending)
|
|
2757
|
+
: null;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
async function recoverPendingAllocation() {
|
|
2761
|
+
try {
|
|
2762
|
+
await withMembershipLock(async () => {
|
|
2763
|
+
reloadIdentityState();
|
|
2764
|
+
await recoverPendingAllocationLocked();
|
|
2765
|
+
});
|
|
2766
|
+
return true;
|
|
2767
|
+
} catch {
|
|
2768
|
+
reloadIdentityState();
|
|
2769
|
+
return browserIdentity.pending_allocation === null;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2433
2773
|
function renderComposer() {
|
|
2434
2774
|
el.composer.hidden = !state.current;
|
|
2435
2775
|
if (!state.current) return;
|
|
2436
2776
|
const joined = joinedHere();
|
|
2437
|
-
|
|
2438
|
-
|
|
2777
|
+
const current = currentIdentity();
|
|
2778
|
+
const allocationMode = !current || state.changingIdentity;
|
|
2779
|
+
el.composeJoin.hidden = joined && !state.changingIdentity;
|
|
2780
|
+
el.composeBox.hidden = !joined || state.changingIdentity;
|
|
2439
2781
|
el.joinError.textContent = "";
|
|
2440
2782
|
el.composeError.textContent = "";
|
|
2441
|
-
if (joined) {
|
|
2783
|
+
if (joined && !state.changingIdentity) {
|
|
2442
2784
|
el.composeIdent.textContent = identity();
|
|
2785
|
+
el.composeIdent.title = identity();
|
|
2443
2786
|
} else {
|
|
2444
|
-
|
|
2445
|
-
el.
|
|
2787
|
+
el.joinName.hidden = !allocationMode;
|
|
2788
|
+
el.joinChangeBtn.hidden = allocationMode || !current;
|
|
2789
|
+
if (allocationMode) {
|
|
2790
|
+
if (!el.joinName.value && current) {
|
|
2791
|
+
el.joinName.value = current.base_name;
|
|
2792
|
+
}
|
|
2793
|
+
el.joinBtn.textContent = current ? "Change name" : "Join room";
|
|
2794
|
+
} else {
|
|
2795
|
+
el.joinBtn.textContent = "Join as " + current.agent_id;
|
|
2796
|
+
}
|
|
2446
2797
|
}
|
|
2447
2798
|
renderReplyTarget();
|
|
2448
2799
|
}
|
|
@@ -2476,109 +2827,140 @@
|
|
|
2476
2827
|
}
|
|
2477
2828
|
|
|
2478
2829
|
async function joinCurrent() {
|
|
2479
|
-
// The Enter key in the name field bypasses the button's disabled
|
|
2480
|
-
// state; gate on it here so two rename loops can never interleave
|
|
2481
|
-
// over the shared localStorage maps.
|
|
2482
2830
|
if (el.joinBtn.disabled) return;
|
|
2483
2831
|
const room = state.current;
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
}
|
|
2832
|
+
if (!room) return;
|
|
2833
|
+
const requestedBase = el.joinName.value;
|
|
2834
|
+
const explicitChange = state.changingIdentity;
|
|
2835
|
+
const changingFrom = state.changingFrom;
|
|
2836
|
+
const previousUiIdentity = identity();
|
|
2490
2837
|
el.joinBtn.disabled = true;
|
|
2491
2838
|
const epoch = state.roomEpoch;
|
|
2492
2839
|
try {
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
const newGhosts = [];
|
|
2512
|
-
const removed = [];
|
|
2513
|
-
for (const [rid, storedName] of Object.entries(joinedMap)) {
|
|
2514
|
-
if (!storedName || storedName === name) continue;
|
|
2515
|
-
try {
|
|
2516
|
-
await postJson("/api/leave", { room: Number(rid), name: storedName });
|
|
2517
|
-
} catch {
|
|
2518
|
-
newGhosts.push({ room: Number(rid), name: storedName });
|
|
2840
|
+
const { j, saved, newIdentity } = await withMembershipLock(async () => {
|
|
2841
|
+
// Web Locks serialize same-origin tabs, but the decision must use
|
|
2842
|
+
// the state read INSIDE the lock. Two initially empty tabs should
|
|
2843
|
+
// share the first allocation, not mint one identity each.
|
|
2844
|
+
reloadIdentityState();
|
|
2845
|
+
const recovered = await recoverPendingAllocationLocked();
|
|
2846
|
+
// A recovery satisfies this click only if it joined the visible
|
|
2847
|
+
// room; a foreign-room or joined:false replay falls through below.
|
|
2848
|
+
if (
|
|
2849
|
+
recovered &&
|
|
2850
|
+
recovered.room_id === room.id &&
|
|
2851
|
+
recovered.joined === true
|
|
2852
|
+
) {
|
|
2853
|
+
return {
|
|
2854
|
+
j: recovered,
|
|
2855
|
+
saved: true,
|
|
2856
|
+
newIdentity: identity(),
|
|
2857
|
+
};
|
|
2519
2858
|
}
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2859
|
+
const current = currentIdentity();
|
|
2860
|
+
|
|
2861
|
+
// An explicit rename was chosen for one visible identity. If a
|
|
2862
|
+
// different tab replaced it while this form was open, do not turn
|
|
2863
|
+
// the stale click into an unintended second rename.
|
|
2864
|
+
if (
|
|
2865
|
+
explicitChange &&
|
|
2866
|
+
changingFrom &&
|
|
2867
|
+
current?.agent_id !== changingFrom
|
|
2868
|
+
) {
|
|
2869
|
+
throw new Error(
|
|
2870
|
+
"the identity changed in another tab; review the current name and try again",
|
|
2871
|
+
);
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
let j;
|
|
2875
|
+
let saved = true;
|
|
2876
|
+
if (
|
|
2877
|
+
current &&
|
|
2878
|
+
(!explicitChange || requestedBase === current.base_name)
|
|
2879
|
+
) {
|
|
2880
|
+
// Journal an uncertain rejoin before the server write. Until the
|
|
2881
|
+
// joined map is durable, recovery may safely undo it with leave.
|
|
2882
|
+
// After the map is saved, this non-explicit ghost is discarded
|
|
2883
|
+
// without touching the live membership.
|
|
2884
|
+
const ghosts = loadGhosts();
|
|
2885
|
+
const existing = ghosts.find(
|
|
2886
|
+
(g) =>
|
|
2887
|
+
g.room === room.id &&
|
|
2888
|
+
g.agent_id === current.agent_id,
|
|
2889
|
+
);
|
|
2890
|
+
if (existing) {
|
|
2891
|
+
existing.explicit_leave = false;
|
|
2892
|
+
} else {
|
|
2893
|
+
ghosts.push({
|
|
2894
|
+
room: room.id,
|
|
2895
|
+
agent_id: current.agent_id,
|
|
2896
|
+
explicit_leave: false,
|
|
2897
|
+
});
|
|
2530
2898
|
}
|
|
2899
|
+
if (!saveGhosts(ghosts)) {
|
|
2900
|
+
throw new Error(
|
|
2901
|
+
"browser storage is unavailable; the room was not joined",
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
j = await postJson("/api/join", {
|
|
2906
|
+
room: room.id,
|
|
2907
|
+
agent_id: current.agent_id,
|
|
2908
|
+
});
|
|
2909
|
+
if (j.agent_id !== current.agent_id || j.room_id !== room.id) {
|
|
2910
|
+
throw new Error(
|
|
2911
|
+
"canonical join response did not match the requested identity",
|
|
2912
|
+
);
|
|
2913
|
+
}
|
|
2914
|
+
const joined = loadJoined();
|
|
2915
|
+
joined[String(room.id)] = current.agent_id;
|
|
2916
|
+
saved = saveJoined(joined);
|
|
2917
|
+
if (saved) {
|
|
2918
|
+
// Safe if this write fails: retryGhosts sees the matching map
|
|
2919
|
+
// and removes the non-explicit record without leaving.
|
|
2920
|
+
removeGhost(room.id, current.agent_id);
|
|
2921
|
+
}
|
|
2922
|
+
} else {
|
|
2923
|
+
if (!requestedBase) {
|
|
2924
|
+
throw new Error("enter a base name first");
|
|
2925
|
+
}
|
|
2926
|
+
const previousAgentId = current?.agent_id || null;
|
|
2927
|
+
const pending = {
|
|
2928
|
+
operation_id: crypto.randomUUID(),
|
|
2929
|
+
room: room.id,
|
|
2930
|
+
base_name: requestedBase,
|
|
2931
|
+
previous_agent_id: previousAgentId,
|
|
2932
|
+
previous_memberships:
|
|
2933
|
+
recordedMemberships(previousAgentId),
|
|
2934
|
+
};
|
|
2935
|
+
if (
|
|
2936
|
+
!saveIdentityState({
|
|
2937
|
+
version: IDENTITY_VERSION,
|
|
2938
|
+
current,
|
|
2939
|
+
pending_allocation: pending,
|
|
2940
|
+
})
|
|
2941
|
+
) {
|
|
2942
|
+
throw new Error(
|
|
2943
|
+
"browser storage is unavailable; no identity was allocated",
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2946
|
+
j = await completePendingAllocationLocked(pending);
|
|
2531
2947
|
}
|
|
2532
|
-
|
|
2533
|
-
}
|
|
2534
|
-
// Same merge discipline for the joined map: re-read it and delete
|
|
2535
|
-
// only the entries we actually left AND that still hold the value
|
|
2536
|
-
// we left under (defense in depth under the lock).
|
|
2537
|
-
const freshMap = loadJoined();
|
|
2538
|
-
for (const [rid, storedName] of removed) {
|
|
2539
|
-
if (freshMap[rid] === storedName) delete freshMap[rid];
|
|
2540
|
-
}
|
|
2541
|
-
freshMap[String(room.id)] = name;
|
|
2542
|
-
const saved = saveJoined(freshMap);
|
|
2543
|
-
// Re-adopting a previously-ghosted (room, name): the mapping above
|
|
2544
|
-
// makes that pair live again, so drop any stale ledger entry for it
|
|
2545
|
-
// before the next sweep can leave the room we just joined.
|
|
2546
|
-
const ledgerNow = loadGhosts();
|
|
2547
|
-
const keptGhosts = ledgerNow.filter(
|
|
2548
|
-
(x) => !(x.room === room.id && x.name === name),
|
|
2549
|
-
);
|
|
2550
|
-
if (keptGhosts.length !== ledgerNow.length) saveGhosts(keptGhosts);
|
|
2551
|
-
return { j, prevIdentity, saved };
|
|
2948
|
+
return { j, saved, newIdentity: identity() };
|
|
2552
2949
|
});
|
|
2553
|
-
|
|
2554
|
-
|
|
2950
|
+
state.changingIdentity = false;
|
|
2951
|
+
state.changingFrom = null;
|
|
2555
2952
|
retryGhosts();
|
|
2556
|
-
|
|
2557
|
-
// but the client cannot record it, so joinedHere() will read false
|
|
2558
|
-
// and the composer would silently show "not joined". Surface it
|
|
2559
|
-
// instead of leaving the user confused.
|
|
2953
|
+
renderComposer();
|
|
2560
2954
|
if (!saved) {
|
|
2561
2955
|
el.joinError.textContent =
|
|
2562
|
-
"joined on the server, but this browser could not save
|
|
2956
|
+
"joined on the server, but this browser could not save the room membership; free browser storage and retry";
|
|
2563
2957
|
}
|
|
2564
|
-
|
|
2565
|
-
// shown now; re-render it even after a room switch so a stale
|
|
2566
|
-
// successful rename cannot leave it showing the old identity.
|
|
2567
|
-
renderComposer();
|
|
2568
|
-
// A rename invalidates identity-derived DECORATION on already-
|
|
2569
|
-
// rendered rows ("you" badges, directed-at-me styling); rebuild
|
|
2570
|
-
// whatever stream is currently shown from its cached rows, or defer
|
|
2571
|
-
// to exitSearch when the stream is hidden behind search results.
|
|
2572
|
-
if (prevIdentity !== name) {
|
|
2958
|
+
if (previousUiIdentity !== newIdentity) {
|
|
2573
2959
|
if (state.searchMode) state.needsRebuild = true;
|
|
2574
2960
|
else rebuildStream();
|
|
2575
2961
|
}
|
|
2576
|
-
// A delayed response must not mutate another visit's marker/UI --
|
|
2577
|
-
// but a RENAME was global, so the room shown now still carries the
|
|
2578
|
-
// old identity's marker state: reset it and re-baseline (fetchMarker
|
|
2579
|
-
// no-ops unless the new identity is joined there).
|
|
2580
2962
|
if (!sameView(room, epoch)) {
|
|
2581
|
-
if (
|
|
2963
|
+
if (previousUiIdentity !== newIdentity) {
|
|
2582
2964
|
state.lastReadSent = 0;
|
|
2583
2965
|
state.marker = -1;
|
|
2584
2966
|
state.markerIdent = null;
|
|
@@ -2587,18 +2969,32 @@
|
|
|
2587
2969
|
}
|
|
2588
2970
|
return;
|
|
2589
2971
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2972
|
+
if (
|
|
2973
|
+
previousUiIdentity &&
|
|
2974
|
+
previousUiIdentity !== newIdentity
|
|
2975
|
+
) {
|
|
2976
|
+
state.roomEpoch += 1;
|
|
2977
|
+
}
|
|
2595
2978
|
state.lastReadSent = 0;
|
|
2596
|
-
state.marker = j.last_read_seq ?? 0;
|
|
2597
|
-
state.markerIdent =
|
|
2979
|
+
state.marker = saved && j.joined ? (j.last_read_seq ?? 0) : -1;
|
|
2980
|
+
state.markerIdent = saved && j.joined ? newIdentity : null;
|
|
2598
2981
|
computeGap();
|
|
2599
2982
|
maybeMarkRead();
|
|
2600
|
-
el.composeText.focus();
|
|
2983
|
+
if (joinedHere()) el.composeText.focus();
|
|
2601
2984
|
} catch (e) {
|
|
2985
|
+
if (previousUiIdentity !== identity()) {
|
|
2986
|
+
state.changingIdentity = false;
|
|
2987
|
+
state.changingFrom = null;
|
|
2988
|
+
state.lastReadSent = 0;
|
|
2989
|
+
state.marker = -1;
|
|
2990
|
+
state.markerIdent = null;
|
|
2991
|
+
state.gap = false;
|
|
2992
|
+
renderComposer();
|
|
2993
|
+
if (state.searchMode) state.needsRebuild = true;
|
|
2994
|
+
else rebuildStream();
|
|
2995
|
+
computeGap();
|
|
2996
|
+
fetchMarker();
|
|
2997
|
+
}
|
|
2602
2998
|
if (sameView(room, epoch)) el.joinError.textContent = e.message;
|
|
2603
2999
|
} finally {
|
|
2604
3000
|
el.joinBtn.disabled = false;
|
|
@@ -2608,23 +3004,61 @@
|
|
|
2608
3004
|
async function leaveCurrent() {
|
|
2609
3005
|
const room = state.current;
|
|
2610
3006
|
const epoch = state.roomEpoch;
|
|
2611
|
-
const
|
|
2612
|
-
if (!room || !
|
|
3007
|
+
const agentId = identity();
|
|
3008
|
+
if (!room || !agentId) return;
|
|
2613
3009
|
try {
|
|
2614
3010
|
// Same lock as join/rename: the leave plus its map update must not
|
|
2615
3011
|
// interleave with another tab's read-modify-write.
|
|
2616
3012
|
await withMembershipLock(async () => {
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
3013
|
+
const ghosts = loadGhosts();
|
|
3014
|
+
const existing = ghosts.find(
|
|
3015
|
+
(g) => g.room === room.id && g.agent_id === agentId,
|
|
3016
|
+
);
|
|
3017
|
+
if (existing) {
|
|
3018
|
+
existing.explicit_leave = true;
|
|
3019
|
+
} else {
|
|
3020
|
+
ghosts.push({
|
|
3021
|
+
room: room.id,
|
|
3022
|
+
agent_id: agentId,
|
|
3023
|
+
explicit_leave: true,
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
if (!saveGhosts(ghosts)) {
|
|
3027
|
+
throw new Error(
|
|
3028
|
+
"browser storage is unavailable; the room was not left",
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// Persist the local leave before the request. If the request or
|
|
3033
|
+
// page dies afterward, the ghost entry retries the server leave.
|
|
2620
3034
|
const joined = loadJoined();
|
|
2621
|
-
if (joined[String(room.id)] ===
|
|
3035
|
+
if (joined[String(room.id)] === agentId) {
|
|
2622
3036
|
delete joined[String(room.id)];
|
|
2623
|
-
saveJoined(joined)
|
|
3037
|
+
if (!saveJoined(joined)) {
|
|
3038
|
+
throw new Error(
|
|
3039
|
+
"leave is recorded, but browser room state could not be updated; it will retry",
|
|
3040
|
+
);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
await postJson("/api/leave", {
|
|
3045
|
+
room: room.id,
|
|
3046
|
+
agent_id: agentId,
|
|
3047
|
+
});
|
|
3048
|
+
|
|
3049
|
+
if (!removeGhost(room.id, agentId)) {
|
|
3050
|
+
throw new Error(
|
|
3051
|
+
"the room was left, but cleanup state could not be saved; free browser storage and retry",
|
|
3052
|
+
);
|
|
2624
3053
|
}
|
|
2625
3054
|
});
|
|
2626
3055
|
} catch (e) {
|
|
2627
|
-
if (sameView(room, epoch))
|
|
3056
|
+
if (sameView(room, epoch)) {
|
|
3057
|
+
if (!joinedHere()) clearReply();
|
|
3058
|
+
renderComposer();
|
|
3059
|
+
const target = joinedHere() ? el.composeError : el.joinError;
|
|
3060
|
+
target.textContent = e.message;
|
|
3061
|
+
}
|
|
2628
3062
|
return;
|
|
2629
3063
|
}
|
|
2630
3064
|
// Composer/reply belong to whatever room is shown NOW.
|
|
@@ -2645,7 +3079,7 @@
|
|
|
2645
3079
|
try {
|
|
2646
3080
|
await postJson("/api/post", {
|
|
2647
3081
|
room: room.id,
|
|
2648
|
-
|
|
3082
|
+
agent_id: identity(),
|
|
2649
3083
|
body,
|
|
2650
3084
|
reply_to_seq: state.replyTo ? state.replyTo.seq : null,
|
|
2651
3085
|
});
|
|
@@ -2708,9 +3142,9 @@
|
|
|
2708
3142
|
async function fetchMarker() {
|
|
2709
3143
|
const room = state.current;
|
|
2710
3144
|
const epoch = state.roomEpoch;
|
|
2711
|
-
const
|
|
2712
|
-
if (!room || !
|
|
2713
|
-
const retryKey = `${room.id}:${epoch}:${
|
|
3145
|
+
const agentId = identity();
|
|
3146
|
+
if (!room || !agentId || !joinedHere()) return;
|
|
3147
|
+
const retryKey = `${room.id}:${epoch}:${agentId}`;
|
|
2714
3148
|
if (state.markerLoading) return;
|
|
2715
3149
|
if (
|
|
2716
3150
|
state.markerRetryKey === retryKey &&
|
|
@@ -2721,7 +3155,7 @@
|
|
|
2721
3155
|
state.markerLoading = true;
|
|
2722
3156
|
try {
|
|
2723
3157
|
const me = await fetchJson(
|
|
2724
|
-
`/api/me?room=${room.id}&
|
|
3158
|
+
`/api/me?room=${room.id}&agent_id=${encodeURIComponent(agentId)}`,
|
|
2725
3159
|
);
|
|
2726
3160
|
if (me.error) throw new Error(me.error);
|
|
2727
3161
|
if (!sameView(room, epoch)) return;
|
|
@@ -2729,23 +3163,23 @@
|
|
|
2729
3163
|
// request and this completion; the epoch bump from the storage
|
|
2730
3164
|
// listener usually catches that, but its event delivery is async,
|
|
2731
3165
|
// so re-check before baselining the wrong identity's marker.
|
|
2732
|
-
if (identity() !==
|
|
3166
|
+
if (identity() !== agentId) return;
|
|
2733
3167
|
// Never LOWER a marker a same-epoch same-identity writer (join,
|
|
2734
3168
|
// markall) advanced while this response was in flight: the server
|
|
2735
3169
|
// marker is monotonic, so the max is the truth; a stale value
|
|
2736
3170
|
// would resurrect a cleared gap and suspend auto-marking.
|
|
2737
3171
|
state.marker = me.joined
|
|
2738
|
-
? state.markerIdent ===
|
|
3172
|
+
? state.markerIdent === agentId && state.marker >= 0
|
|
2739
3173
|
? Math.max(state.marker, me.last_read_seq)
|
|
2740
3174
|
: me.last_read_seq
|
|
2741
3175
|
: -1;
|
|
2742
|
-
state.markerIdent =
|
|
3176
|
+
state.markerIdent = agentId;
|
|
2743
3177
|
state.markerRetryKey = "";
|
|
2744
3178
|
state.markerRetryAt = 0;
|
|
2745
3179
|
computeGap();
|
|
2746
3180
|
maybeMarkRead();
|
|
2747
3181
|
} catch {
|
|
2748
|
-
if (sameView(room, epoch) && identity() ===
|
|
3182
|
+
if (sameView(room, epoch) && identity() === agentId) {
|
|
2749
3183
|
state.markerRetryKey = retryKey;
|
|
2750
3184
|
state.markerRetryAt = Date.now() + 5_000;
|
|
2751
3185
|
}
|
|
@@ -2757,8 +3191,8 @@
|
|
|
2757
3191
|
function maybeMarkRead() {
|
|
2758
3192
|
const room = state.current;
|
|
2759
3193
|
const epoch = state.roomEpoch;
|
|
2760
|
-
const
|
|
2761
|
-
if (!room || !
|
|
3194
|
+
const agentId = identity();
|
|
3195
|
+
if (!room || !agentId || !joinedHere()) return;
|
|
2762
3196
|
// Search mode hides the live stream: follow/scroll state describes
|
|
2763
3197
|
// the RESULTS layout, and the stream tail was never on screen, so
|
|
2764
3198
|
// marking it read here would claim unseen messages (monotonically,
|
|
@@ -2768,12 +3202,10 @@
|
|
|
2768
3202
|
// tab renames, marker/gap state still describes the old name until
|
|
2769
3203
|
// fetchMarker re-baselines; posting meanwhile would graft the old
|
|
2770
3204
|
// identity's read position onto the new name.
|
|
2771
|
-
if (state.markerIdent !==
|
|
3205
|
+
if (state.markerIdent !== agentId) return;
|
|
2772
3206
|
if (document.visibilityState !== "visible") return;
|
|
2773
|
-
//
|
|
2774
|
-
|
|
2775
|
-
// marker as a read receipt.
|
|
2776
|
-
if (!state.follow) return;
|
|
3207
|
+
// follow can be stale after an append; the durable marker needs both.
|
|
3208
|
+
if (!state.follow || !nearBottom()) return;
|
|
2777
3209
|
// Re-derive the gap from current state rather than trusting a stale
|
|
2778
3210
|
// flag: non-initial appends (including the degraded recovery path
|
|
2779
3211
|
// after a dropped initial load) never ran computeGap.
|
|
@@ -2787,7 +3219,11 @@
|
|
|
2787
3219
|
: 0;
|
|
2788
3220
|
if (last <= state.lastReadSent) return;
|
|
2789
3221
|
state.lastReadSent = last;
|
|
2790
|
-
postJson("/api/read", {
|
|
3222
|
+
postJson("/api/read", {
|
|
3223
|
+
room: room.id,
|
|
3224
|
+
agent_id: agentId,
|
|
3225
|
+
seq: last,
|
|
3226
|
+
})
|
|
2791
3227
|
.then((r) => {
|
|
2792
3228
|
// A delayed response must not mutate another visit's state.
|
|
2793
3229
|
if (sameView(room, epoch)) state.marker = r.last_read_seq;
|
|
@@ -2797,8 +3233,44 @@
|
|
|
2797
3233
|
});
|
|
2798
3234
|
}
|
|
2799
3235
|
|
|
3236
|
+
// Local activity state is independent of membership and server read
|
|
3237
|
+
// gaps. Read-only viewers can clear dots, and only the rendered tail is
|
|
3238
|
+
// recorded. Scroll frames are separately gated to the visit that queued
|
|
3239
|
+
// them.
|
|
3240
|
+
function noteSeen() {
|
|
3241
|
+
const room = state.current;
|
|
3242
|
+
if (!room) return;
|
|
3243
|
+
// A hidden live stream or historical slice is not the current tail.
|
|
3244
|
+
if (state.searchMode || state.historicalWindow) return;
|
|
3245
|
+
if (document.visibilityState !== "visible") return;
|
|
3246
|
+
// follow can be stale until an appended tail is actually scrolled.
|
|
3247
|
+
if (!state.follow || !nearBottom()) return;
|
|
3248
|
+
const last = state.msgs.length
|
|
3249
|
+
? state.msgs[state.msgs.length - 1].seq
|
|
3250
|
+
: 0;
|
|
3251
|
+
// Never let a shorter rendered window resurrect a cleared dot.
|
|
3252
|
+
if (!last || !mergeSeen(state.seen, room.id, last)) return;
|
|
3253
|
+
// Update before awaiting the lock so scroll events do not queue repeats.
|
|
3254
|
+
void saveSeen(room.id, last);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
2800
3257
|
// ---- room deletion ------------------------------------------------------
|
|
2801
3258
|
|
|
3259
|
+
async function forgetDeletedRoom(roomId) {
|
|
3260
|
+
await withMembershipLock(() => {
|
|
3261
|
+
const joined = loadJoined();
|
|
3262
|
+
delete joined[String(roomId)];
|
|
3263
|
+
saveJoined(joined);
|
|
3264
|
+
saveGhosts(loadGhosts().filter((g) => g.room !== roomId));
|
|
3265
|
+
});
|
|
3266
|
+
await forgetSeen(roomId);
|
|
3267
|
+
delete state.drafts[roomId];
|
|
3268
|
+
try {
|
|
3269
|
+
const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
|
|
3270
|
+
if (saved?.id === roomId) localStorage.removeItem(ROOM_KEY);
|
|
3271
|
+
} catch {}
|
|
3272
|
+
}
|
|
3273
|
+
|
|
2802
3274
|
function offerDelete() {
|
|
2803
3275
|
const room = state.current;
|
|
2804
3276
|
if (!room) return;
|
|
@@ -2822,20 +3294,9 @@
|
|
|
2822
3294
|
return;
|
|
2823
3295
|
}
|
|
2824
3296
|
el.deleteYes.disabled = false;
|
|
2825
|
-
// Forget every local trace
|
|
2826
|
-
//
|
|
2827
|
-
await
|
|
2828
|
-
const joined = loadJoined();
|
|
2829
|
-
delete joined[String(room.id)];
|
|
2830
|
-
saveJoined(joined);
|
|
2831
|
-
});
|
|
2832
|
-
delete state.seen[room.id];
|
|
2833
|
-
saveSeen();
|
|
2834
|
-
delete state.drafts[room.id];
|
|
2835
|
-
try {
|
|
2836
|
-
const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
|
|
2837
|
-
if (saved && saved.id === room.id) localStorage.removeItem(ROOM_KEY);
|
|
2838
|
-
} catch {}
|
|
3297
|
+
// Forget every local trace even if the user switched rooms while the
|
|
3298
|
+
// delete request was in flight.
|
|
3299
|
+
await forgetDeletedRoom(room.id);
|
|
2839
3300
|
// Tear down the UI only if the deleted room is still the one shown;
|
|
2840
3301
|
// after a switch, the current room's view (and draft) must survive.
|
|
2841
3302
|
if (sameView(room, epoch)) {
|
|
@@ -2875,12 +3336,13 @@
|
|
|
2875
3336
|
state.gap = false;
|
|
2876
3337
|
state.noOlderHistory = false;
|
|
2877
3338
|
state.needsRebuild = false; // a deferred rebuild belongs to the old room
|
|
3339
|
+
state.changingIdentity = false;
|
|
3340
|
+
state.changingFrom = null;
|
|
2878
3341
|
state.roomEpoch += 1;
|
|
2879
3342
|
try {
|
|
2880
3343
|
localStorage.setItem(ROOM_KEY, JSON.stringify({ id: room.id }));
|
|
2881
3344
|
} catch {}
|
|
2882
|
-
|
|
2883
|
-
saveSeen();
|
|
3345
|
+
// Selecting a room is not proof that its messages rendered.
|
|
2884
3346
|
el.msgs.textContent = "";
|
|
2885
3347
|
el.older.hidden = true;
|
|
2886
3348
|
el.streamWrap.hidden = false;
|
|
@@ -2933,6 +3395,8 @@
|
|
|
2933
3395
|
updateJump();
|
|
2934
3396
|
}
|
|
2935
3397
|
if (!was && state.follow) maybeMarkRead(); // caught up by scrolling
|
|
3398
|
+
// The scroll position is already current, so no frame deferral is needed.
|
|
3399
|
+
noteSeen();
|
|
2936
3400
|
});
|
|
2937
3401
|
|
|
2938
3402
|
el.jump.addEventListener("click", () => {
|
|
@@ -2953,6 +3417,8 @@
|
|
|
2953
3417
|
updateJump();
|
|
2954
3418
|
scrollBottom();
|
|
2955
3419
|
maybeMarkRead();
|
|
3420
|
+
// Explicit tail navigation has already realized the scroll position.
|
|
3421
|
+
noteSeen();
|
|
2956
3422
|
});
|
|
2957
3423
|
|
|
2958
3424
|
el.older.addEventListener("click", loadOlder);
|
|
@@ -2961,18 +3427,18 @@
|
|
|
2961
3427
|
// whole room read (same semantics as an agent's bare mark_read).
|
|
2962
3428
|
const room = state.current;
|
|
2963
3429
|
const epoch = state.roomEpoch;
|
|
2964
|
-
const
|
|
3430
|
+
const agentId = identity();
|
|
2965
3431
|
const last = state.msgs.length
|
|
2966
3432
|
? state.msgs[state.msgs.length - 1].seq
|
|
2967
3433
|
: 0;
|
|
2968
|
-
if (!room || !
|
|
3434
|
+
if (!room || !agentId || !joinedHere() || !last) return;
|
|
2969
3435
|
// Same identity guard as maybeMarkRead: never advance a marker whose
|
|
2970
3436
|
// baseline was fetched for a different (pre-rename) identity.
|
|
2971
|
-
if (state.markerIdent !==
|
|
3437
|
+
if (state.markerIdent !== agentId) return;
|
|
2972
3438
|
try {
|
|
2973
3439
|
const r = await postJson("/api/read", {
|
|
2974
3440
|
room: room.id,
|
|
2975
|
-
|
|
3441
|
+
agent_id: agentId,
|
|
2976
3442
|
seq: last,
|
|
2977
3443
|
});
|
|
2978
3444
|
// A delayed response must not mutate another visit's state.
|
|
@@ -2995,13 +3461,18 @@
|
|
|
2995
3461
|
}
|
|
2996
3462
|
});
|
|
2997
3463
|
el.leaveBtn.addEventListener("click", leaveCurrent);
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3464
|
+
function startChangingIdentity() {
|
|
3465
|
+
const current = currentIdentity();
|
|
3466
|
+
if (!current) return;
|
|
3467
|
+
state.changingIdentity = true;
|
|
3468
|
+
state.changingFrom = current.agent_id;
|
|
3469
|
+
el.joinName.value = current.base_name;
|
|
3470
|
+
renderComposer();
|
|
3002
3471
|
el.joinName.focus();
|
|
3003
3472
|
el.joinName.select();
|
|
3004
|
-
}
|
|
3473
|
+
}
|
|
3474
|
+
el.identBtn.addEventListener("click", startChangingIdentity);
|
|
3475
|
+
el.joinChangeBtn.addEventListener("click", startChangingIdentity);
|
|
3005
3476
|
el.replyTarget.addEventListener("click", clearReply);
|
|
3006
3477
|
el.send.addEventListener("click", sendMessage);
|
|
3007
3478
|
el.composeText.addEventListener("input", autosize);
|
|
@@ -3013,8 +3484,11 @@
|
|
|
3013
3484
|
if (e.key === "Escape") clearReply();
|
|
3014
3485
|
});
|
|
3015
3486
|
el.joinName.addEventListener("keydown", (e) => {
|
|
3016
|
-
|
|
3017
|
-
|
|
3487
|
+
if (e.key === "Escape" && currentIdentity()) {
|
|
3488
|
+
state.changingIdentity = false;
|
|
3489
|
+
state.changingFrom = null;
|
|
3490
|
+
renderComposer();
|
|
3491
|
+
}
|
|
3018
3492
|
});
|
|
3019
3493
|
el.search.addEventListener("keydown", (e) => {
|
|
3020
3494
|
if (e.key === "Enter") {
|
|
@@ -3036,7 +3510,11 @@
|
|
|
3036
3510
|
if (document.hidden) return;
|
|
3037
3511
|
loadNew(false);
|
|
3038
3512
|
loadRooms();
|
|
3039
|
-
|
|
3513
|
+
// Record hidden arrivals only after the visible frame can paint them.
|
|
3514
|
+
requestAnimationFrame(() => {
|
|
3515
|
+
noteSeen();
|
|
3516
|
+
maybeMarkRead();
|
|
3517
|
+
});
|
|
3040
3518
|
});
|
|
3041
3519
|
|
|
3042
3520
|
// Another tab changing the identity or the membership map invalidates
|
|
@@ -3046,7 +3524,28 @@
|
|
|
3046
3524
|
// only for OTHER documents, so same-tab writes never loop through here;
|
|
3047
3525
|
// a discarded in-flight initial load re-arms via pendingInitial.)
|
|
3048
3526
|
window.addEventListener("storage", (e) => {
|
|
3527
|
+
if (e.key === SEEN_KEY) {
|
|
3528
|
+
const before = parseSeen(e.oldValue);
|
|
3529
|
+
const after = parseSeen(e.newValue);
|
|
3530
|
+
for (const room of Object.keys(before)) {
|
|
3531
|
+
if (!(room in after)) delete state.seen[room];
|
|
3532
|
+
}
|
|
3533
|
+
for (const [room, seq] of Object.entries(after)) {
|
|
3534
|
+
mergeSeen(state.seen, room, seq);
|
|
3535
|
+
}
|
|
3536
|
+
renderRooms();
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
if (e.key === GHOST_KEY) {
|
|
3540
|
+
retryGhosts();
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3049
3543
|
if (e.key !== IDENT_KEY && e.key !== JOINED_KEY) return;
|
|
3544
|
+
if (e.key === IDENT_KEY) {
|
|
3545
|
+
reloadIdentityState();
|
|
3546
|
+
state.changingIdentity = false;
|
|
3547
|
+
state.changingFrom = null;
|
|
3548
|
+
}
|
|
3050
3549
|
state.roomEpoch += 1;
|
|
3051
3550
|
state.lastReadSent = 0;
|
|
3052
3551
|
state.marker = -1;
|
|
@@ -3064,18 +3563,46 @@
|
|
|
3064
3563
|
computeGap();
|
|
3065
3564
|
fetchMarker();
|
|
3066
3565
|
}
|
|
3566
|
+
if (e.key === IDENT_KEY) {
|
|
3567
|
+
if (browserIdentity.pending_allocation) {
|
|
3568
|
+
recoverPendingAllocation().then((recovered) => {
|
|
3569
|
+
if (!recovered) return;
|
|
3570
|
+
state.roomEpoch += 1;
|
|
3571
|
+
state.lastReadSent = 0;
|
|
3572
|
+
state.marker = -1;
|
|
3573
|
+
state.markerIdent = null;
|
|
3574
|
+
state.gap = false;
|
|
3575
|
+
renderComposer();
|
|
3576
|
+
if (state.current) {
|
|
3577
|
+
if (state.searchMode) state.needsRebuild = true;
|
|
3578
|
+
else rebuildStream();
|
|
3579
|
+
computeGap();
|
|
3580
|
+
fetchMarker();
|
|
3581
|
+
}
|
|
3582
|
+
retryGhosts();
|
|
3583
|
+
});
|
|
3584
|
+
} else {
|
|
3585
|
+
retryGhosts();
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3067
3588
|
});
|
|
3068
3589
|
|
|
3069
3590
|
setInterval(() => {
|
|
3070
3591
|
if (!document.hidden) loadNew(false);
|
|
3071
3592
|
}, POLL_MS);
|
|
3072
3593
|
setInterval(() => {
|
|
3073
|
-
if (!document.hidden)
|
|
3594
|
+
if (!document.hidden) {
|
|
3595
|
+
loadRooms();
|
|
3596
|
+
// Failed leaves and uncertain rejoins must converge even when the
|
|
3597
|
+
// user keeps this page open and performs no later identity action.
|
|
3598
|
+
if (loadGhosts().length > 0) retryGhosts();
|
|
3599
|
+
}
|
|
3074
3600
|
}, ROOMS_MS);
|
|
3075
3601
|
|
|
3076
3602
|
(async function init() {
|
|
3077
3603
|
showPlaceholder("Loading...", "");
|
|
3078
|
-
|
|
3604
|
+
const recovered = await recoverPendingAllocation();
|
|
3605
|
+
if (recovered) retryGhosts();
|
|
3079
3606
|
await loadRooms();
|
|
3080
3607
|
let savedId = null;
|
|
3081
3608
|
try {
|
|
@@ -3088,7 +3615,7 @@
|
|
|
3088
3615
|
} else if (state.rooms.length) {
|
|
3089
3616
|
showPlaceholder(
|
|
3090
3617
|
"Pick a room.",
|
|
3091
|
-
"Rooms are listed on the left; the
|
|
3618
|
+
"Rooms are listed on the left; the activity dot marks messages you have not seen.",
|
|
3092
3619
|
);
|
|
3093
3620
|
}
|
|
3094
3621
|
})();
|