@decentnetwork/beagle 0.1.88 → 0.1.90

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.87";
1
+ window.__DK_UI_VERSION="0.1.90";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -380,11 +380,13 @@ window.__DK_UI_VERSION="0.1.87";
380
380
  const t = setInterval(refresh, 2500);
381
381
  return () => clearInterval(t);
382
382
  }, [refresh]);
383
+ const inFlight = React.useRef(/* @__PURE__ */ new Set());
383
384
  const loadThread = React.useCallback(async (peerId) => {
384
- if (!peerId)
385
+ if (!peerId || inFlight.current.has(peerId))
385
386
  return;
387
+ inFlight.current.add(peerId);
386
388
  try {
387
- const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId));
389
+ const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId) + "&limit=200");
388
390
  const arr = d.chats && d.chats[peerId] || [];
389
391
  await prefetchThreadFiles(arr);
390
392
  const msgs = arr.map((m) => ({
@@ -417,6 +419,8 @@ window.__DK_UI_VERSION="0.1.87";
417
419
  const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
418
420
  setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
419
421
  } catch (e) {
422
+ } finally {
423
+ inFlight.current.delete(peerId);
420
424
  }
421
425
  }, []);
422
426
  return Object.assign({}, snap, { threads, refresh, loadThread });
@@ -521,6 +525,9 @@ window.__DK_UI_VERSION="0.1.87";
521
525
  minWidth: 0
522
526
  } }, dkContactPreview(peer.lastMsg) || peer.lastMsg))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, peer.lastTime), peer.unread ? /* @__PURE__ */ React.createElement(Unread, { n: peer.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
523
527
  }
528
+ function dkActivityTs(p) {
529
+ return Math.max(p.lastTs || 0, p.acceptedAt || 0, p.requestedAt || 0);
530
+ }
524
531
  function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed }) {
525
532
  const [q, setQ] = React.useState("");
526
533
  const [addr, setAddr] = React.useState("");
@@ -558,12 +565,9 @@ window.__DK_UI_VERSION="0.1.87";
558
565
  const s = (p.alias || "") + " " + p.userId + " " + p.ip;
559
566
  return s.toLowerCase().includes(q.toLowerCase());
560
567
  }).slice().sort((a, b) => {
561
- const ta = a.lastTs || 0, tb = b.lastTs || 0;
568
+ const ta = dkActivityTs(a), tb = dkActivityTs(b);
562
569
  if (ta !== tb)
563
570
  return tb - ta;
564
- const aa = a.acceptedAt || 0, ba = b.acceptedAt || 0;
565
- if (aa !== ba)
566
- return ba - aa;
567
571
  return String(a.alias || a.userId || "").localeCompare(String(b.alias || b.userId || ""));
568
572
  });
569
573
  const online = peers.filter((p) => p.online).length;
@@ -2720,6 +2724,35 @@ ${peer.address}`
2720
2724
  )));
2721
2725
  return /* @__PURE__ */ React.createElement(Card, { label: zh ? "\u663E\u793A" : "Display" }, /* @__PURE__ */ React.createElement("div", { style: { ...row, borderBottom: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("span", { style: name }, zh ? "\u8BED\u8A00" : "Language"), seg("lang", t.lang, (v) => setTweak("lang", v), [["en", "EN"], ["zh", "\u4E2D\u6587"]])), /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: name }, zh ? "\u4E3B\u9898" : "Theme"), seg("theme", t.theme, (v) => setTweak("theme", v), [["dark", zh ? "\u6DF1\u8272" : "Dark"], ["light", zh ? "\u6D45\u8272" : "Light"]])));
2722
2726
  }
2727
+ function DkAlertSettings({ t, setTweak }) {
2728
+ const zh = t.lang === "zh";
2729
+ const toggle = (key, label, sub, last) => /* @__PURE__ */ React.createElement("label", { style: {
2730
+ display: "flex",
2731
+ alignItems: "center",
2732
+ gap: 13,
2733
+ padding: "13px 16px",
2734
+ cursor: "pointer",
2735
+ borderBottom: last ? "none" : "1px solid var(--line)"
2736
+ } }, /* @__PURE__ */ React.createElement(
2737
+ "input",
2738
+ {
2739
+ type: "checkbox",
2740
+ checked: t[key] !== false,
2741
+ onChange: (e) => setTweak(key, e.target.checked),
2742
+ style: { width: 16, height: 16, accentColor: "var(--accent)" }
2743
+ }
2744
+ ), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, label), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, sub)));
2745
+ return /* @__PURE__ */ React.createElement(Card, { label: zh ? "\u63D0\u9192" : "Alerts" }, toggle(
2746
+ "alertSound",
2747
+ zh ? "\u65B0\u6D88\u606F\u63D0\u793A\u97F3" : "Sound for new messages",
2748
+ zh ? "\u6536\u5230\u65B0\u6D88\u606F\u6216\u597D\u53CB\u8BF7\u6C42\u65F6\u64AD\u653E\u77ED\u4FC3\u63D0\u793A\u97F3\u3002\u6B63\u5728\u770B\u7684\u5F53\u524D\u4F1A\u8BDD\u4E0D\u4F1A\u54CD\u3002" : "A short chime when a message or friend request arrives. Silent for the conversation you are reading."
2749
+ ), toggle(
2750
+ "alertFlash",
2751
+ zh ? "\u6807\u7B7E\u9875\u95EA\u70C1" : "Flash the tab",
2752
+ zh ? "\u7A97\u53E3\u4E0D\u5728\u524D\u53F0\u65F6\uFF0C\u6807\u7B7E\u6807\u9898\u548C\u56FE\u6807\u95EA\u70C1\u63D0\u793A\uFF0C\u56DE\u5230\u7A97\u53E3\u5373\u505C\u6B62\u3002" : "While this window is in the background, the tab title and icon blink until you come back.",
2753
+ true
2754
+ ));
2755
+ }
2723
2756
  function ProfileTab({ T, me, onEdit, t, setTweak }) {
2724
2757
  var _a;
2725
2758
  const [qr, setQr] = React.useState(null);
@@ -2741,7 +2774,7 @@ ${peer.address}`
2741
2774
  },
2742
2775
  style: { width: 16, height: 16, accentColor: "var(--accent)" }
2743
2776
  }
2744
- ), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, T && T.autoAcceptLabel || "Auto-accept friend requests"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, T && T.autoAcceptSub || "Off: requests wait in the sidebar for your approval.")))), hostHasKeyBackup && /* @__PURE__ */ React.createElement(DkKeyBackup, { t: t || {} }), t && setTweak && /* @__PURE__ */ React.createElement(DkDisplaySettings, { t, setTweak }), /* @__PURE__ */ React.createElement(Card, { label: T.network }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.virtualIp, value: me.ip, copy: true }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.wireLabel, value: `${me.wire} \xB7 lossless`, mono: false }), /* @__PURE__ */ React.createElement(DkVersionRow, { T, me }), /* @__PURE__ */ React.createElement(DkUpdateCheck, { T })), /* @__PURE__ */ React.createElement(Card, { label: T.dangerZone }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 8, flexShrink: 0, background: "color-mix(in oklab, var(--danger), transparent 86%)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--danger)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 17, stroke: 2 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--danger)" } }, T.deleteNode), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)", marginTop: 1 } }, T.deleteSub)), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", size: "sm" }, T.delete)))), qr && /* @__PURE__ */ React.createElement(DkQrModal, { T, value: qr.value, label: qr.label, onClose: () => setQr(null) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
2777
+ ), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, T && T.autoAcceptLabel || "Auto-accept friend requests"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, T && T.autoAcceptSub || "Off: requests wait in the sidebar for your approval.")))), hostHasKeyBackup && /* @__PURE__ */ React.createElement(DkKeyBackup, { t: t || {} }), t && setTweak && /* @__PURE__ */ React.createElement(DkAlertSettings, { t, setTweak }), t && setTweak && /* @__PURE__ */ React.createElement(DkDisplaySettings, { t, setTweak }), /* @__PURE__ */ React.createElement(Card, { label: T.network }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.virtualIp, value: me.ip, copy: true }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.wireLabel, value: `${me.wire} \xB7 lossless`, mono: false }), /* @__PURE__ */ React.createElement(DkVersionRow, { T, me }), /* @__PURE__ */ React.createElement(DkUpdateCheck, { T })), /* @__PURE__ */ React.createElement(Card, { label: T.dangerZone }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 8, flexShrink: 0, background: "color-mix(in oklab, var(--danger), transparent 86%)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--danger)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 17, stroke: 2 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--danger)" } }, T.deleteNode), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)", marginTop: 1 } }, T.deleteSub)), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", size: "sm" }, T.delete)))), qr && /* @__PURE__ */ React.createElement(DkQrModal, { T, value: qr.value, label: qr.label, onClose: () => setQr(null) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
2745
2778
  if (onEdit)
2746
2779
  onEdit(name, description);
2747
2780
  setEditing(false);
@@ -4290,6 +4323,181 @@ ${peer.address}`
4290
4323
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.peerRouting, count: peers.length }), /* @__PURE__ */ React.createElement(PeerTable, { T, peers, onOpenChat })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.exitNodes, trailing: /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "plus", size: "sm" }, T.addExit)) }), activeExit && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderRadius: 10, background: "color-mix(in oklab, var(--warn), transparent 90%)", border: "1px solid color-mix(in oklab, var(--warn), transparent 70%)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "route", size: 17, color: "var(--warn)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, T.egressVia), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: activeExit }, activeExit), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "unlink", size: "sm", onClick: () => onSetExit(null) }, T.stopRouting)), /* @__PURE__ */ React.createElement("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit }))))));
4291
4324
  }
4292
4325
  Object.assign(window, { NetworkTab });
4326
+ var dkAudioCtx = null;
4327
+ function dkAudio() {
4328
+ if (dkAudioCtx && dkAudioCtx.state !== "closed")
4329
+ return dkAudioCtx;
4330
+ try {
4331
+ dkAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
4332
+ } catch (e) {
4333
+ dkAudioCtx = null;
4334
+ }
4335
+ return dkAudioCtx;
4336
+ }
4337
+ function dkUnlockAudioOnGesture() {
4338
+ const unlock = () => {
4339
+ const c = dkAudio();
4340
+ if (c && c.state === "suspended")
4341
+ c.resume().catch(() => {
4342
+ });
4343
+ };
4344
+ window.addEventListener("pointerdown", unlock, { passive: true });
4345
+ window.addEventListener("keydown", unlock, { passive: true });
4346
+ return () => {
4347
+ window.removeEventListener("pointerdown", unlock);
4348
+ window.removeEventListener("keydown", unlock);
4349
+ };
4350
+ }
4351
+ function dkChime(kind) {
4352
+ const c = dkAudio();
4353
+ if (!c)
4354
+ return;
4355
+ if (c.state === "suspended")
4356
+ c.resume().catch(() => {
4357
+ });
4358
+ const notes = kind === "request" ? [[660, 0], [880, 0.14]] : [[880, 0], [1175, 0.11]];
4359
+ const t0 = c.currentTime;
4360
+ for (const [freq, at] of notes) {
4361
+ const o = c.createOscillator(), g = c.createGain();
4362
+ o.type = "sine";
4363
+ o.frequency.value = freq;
4364
+ o.connect(g);
4365
+ g.connect(c.destination);
4366
+ const t = t0 + at;
4367
+ g.gain.setValueAtTime(1e-4, t);
4368
+ g.gain.exponentialRampToValueAtTime(0.18, t + 0.015);
4369
+ g.gain.exponentialRampToValueAtTime(1e-4, t + 0.17);
4370
+ o.start(t);
4371
+ o.stop(t + 0.2);
4372
+ }
4373
+ }
4374
+ var dkFlash = { timer: null, title: "", link: null, href: null, badged: null, on: false, n: 0, label: "" };
4375
+ function dkBadgedIcon(href) {
4376
+ return new Promise((res, rej) => {
4377
+ const img = new Image();
4378
+ img.onload = () => {
4379
+ try {
4380
+ if (!img.naturalWidth)
4381
+ return rej(new Error("icon has no intrinsic size"));
4382
+ const s = 64, cv = document.createElement("canvas");
4383
+ cv.width = s;
4384
+ cv.height = s;
4385
+ const cx = cv.getContext("2d");
4386
+ cx.drawImage(img, 0, 0, s, s);
4387
+ cx.beginPath();
4388
+ cx.arc(s - 14, 14, 12, 0, Math.PI * 2);
4389
+ cx.fillStyle = "#e5484d";
4390
+ cx.fill();
4391
+ cx.lineWidth = 3;
4392
+ cx.strokeStyle = "#fff";
4393
+ cx.stroke();
4394
+ res(cv.toDataURL("image/png"));
4395
+ } catch (e) {
4396
+ rej(e);
4397
+ }
4398
+ };
4399
+ img.onerror = () => rej(new Error("icon failed to load"));
4400
+ img.src = href;
4401
+ });
4402
+ }
4403
+ function dkFlashTick() {
4404
+ dkFlash.on = !dkFlash.on;
4405
+ document.title = `(${dkFlash.n}) ${dkFlash.on ? dkFlash.label : dkFlash.title}`;
4406
+ }
4407
+ function dkFlashStart(label, count) {
4408
+ dkFlash.n += count || 1;
4409
+ dkFlash.label = label;
4410
+ if (dkFlash.timer)
4411
+ return;
4412
+ dkFlash.title = document.title;
4413
+ dkFlash.link = document.querySelector('link[rel~="icon"]');
4414
+ dkFlash.href = dkFlash.link ? dkFlash.link.href : null;
4415
+ dkFlash.badged = null;
4416
+ dkFlash.on = false;
4417
+ if (dkFlash.href) {
4418
+ dkBadgedIcon(dkFlash.href).then((b) => {
4419
+ dkFlash.badged = b;
4420
+ if (dkFlash.timer && dkFlash.link)
4421
+ dkFlash.link.href = b;
4422
+ }).catch(() => {
4423
+ });
4424
+ }
4425
+ dkFlashTick();
4426
+ dkFlash.timer = setInterval(dkFlashTick, 1e3);
4427
+ }
4428
+ function dkFlashStop() {
4429
+ if (!dkFlash.timer)
4430
+ return;
4431
+ clearInterval(dkFlash.timer);
4432
+ dkFlash.timer = null;
4433
+ dkFlash.n = 0;
4434
+ document.title = dkFlash.title;
4435
+ if (dkFlash.link && dkFlash.href)
4436
+ dkFlash.link.href = dkFlash.href;
4437
+ }
4438
+ function dkLooking() {
4439
+ return document.visibilityState === "visible" && document.hasFocus();
4440
+ }
4441
+ function useIncomingAlerts({ T, peers, requests, loaded, activeId, sound = true, flash = true }) {
4442
+ const prev = React.useRef(null);
4443
+ React.useEffect(() => dkUnlockAudioOnGesture(), []);
4444
+ React.useEffect(() => {
4445
+ if (!flash)
4446
+ dkFlashStop();
4447
+ }, [flash]);
4448
+ React.useEffect(() => {
4449
+ const seen = () => {
4450
+ if (dkLooking())
4451
+ dkFlashStop();
4452
+ };
4453
+ window.addEventListener("focus", seen);
4454
+ window.addEventListener("pointerdown", seen, { passive: true });
4455
+ document.addEventListener("visibilitychange", seen);
4456
+ return () => {
4457
+ window.removeEventListener("focus", seen);
4458
+ window.removeEventListener("pointerdown", seen);
4459
+ document.removeEventListener("visibilitychange", seen);
4460
+ dkFlashStop();
4461
+ };
4462
+ }, []);
4463
+ React.useEffect(() => {
4464
+ if (!loaded)
4465
+ return;
4466
+ const byId = /* @__PURE__ */ new Map();
4467
+ for (const p of peers || []) {
4468
+ const dir = p.lastDir || (String(p.lastMsg || "").startsWith("you: ") ? "out" : "in");
4469
+ byId.set(p.id, { ts: p.lastTs || 0, dir });
4470
+ }
4471
+ const reqIds = new Set((requests || []).map((r) => r.id));
4472
+ const was = prev.current;
4473
+ prev.current = { byId, reqIds };
4474
+ if (!was)
4475
+ return;
4476
+ let msgs = 0, elsewhere = 0, reqs = 0;
4477
+ for (const [id, cur] of byId) {
4478
+ if (cur.dir !== "in" || !cur.ts)
4479
+ continue;
4480
+ const old = was.byId.get(id);
4481
+ if (old ? cur.ts <= old.ts : Date.now() - cur.ts > 3e4)
4482
+ continue;
4483
+ msgs += 1;
4484
+ if (id !== activeId)
4485
+ elsewhere += 1;
4486
+ }
4487
+ for (const id of reqIds)
4488
+ if (!was.reqIds.has(id))
4489
+ reqs += 1;
4490
+ if (!msgs && !reqs)
4491
+ return;
4492
+ const looking = dkLooking();
4493
+ if (sound && !(looking && !reqs && !elsewhere))
4494
+ dkChime(reqs && !msgs ? "request" : "message");
4495
+ if (flash && !looking) {
4496
+ const label = msgs ? T && T.alertMsg || "New message" : T && T.alertReq || "New friend request";
4497
+ dkFlashStart(label, msgs + reqs);
4498
+ }
4499
+ }, [peers, requests, loaded, activeId, T, sound, flash]);
4500
+ }
4293
4501
  var TWEAKS_STORAGE_KEY = "decentlan.tweaks";
4294
4502
  function useTweaks(defaults) {
4295
4503
  const [values, setValues] = React.useState(() => {
@@ -4347,7 +4555,9 @@ ${peer.address}`
4347
4555
  "accent": "#7B6CF6",
4348
4556
  "density": "compact",
4349
4557
  "lang": "en",
4350
- "startTab": "chat"
4558
+ "startTab": "chat",
4559
+ "alertSound": true,
4560
+ "alertFlash": true
4351
4561
  }
4352
4562
  );
4353
4563
  function dkDetectLang() {
@@ -4416,6 +4626,8 @@ ${peer.address}`
4416
4626
  network: "Network",
4417
4627
  apps: "Apps",
4418
4628
  profile: "Profile",
4629
+ alertMsg: "New message",
4630
+ alertReq: "New friend request",
4419
4631
  recommended: "Discover",
4420
4632
  registered: "Names",
4421
4633
  here: "Here",
@@ -4596,6 +4808,8 @@ ${peer.address}`
4596
4808
  network: "\u7F51\u7EDC",
4597
4809
  apps: "\u5E94\u7528",
4598
4810
  profile: "\u6211\u7684",
4811
+ alertMsg: "\u65B0\u6D88\u606F",
4812
+ alertReq: "\u65B0\u7684\u597D\u53CB\u8BF7\u6C42",
4599
4813
  recommended: "\u63A8\u8350",
4600
4814
  registered: "\u540D\u5F55",
4601
4815
  here: "\u5728\u573A",
@@ -4988,6 +5202,15 @@ ${peer.address}`
4988
5202
  const T = STR[t.lang] || STR.en;
4989
5203
  const vars = dkTheme(t.theme, t.accent);
4990
5204
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
5205
+ useIncomingAlerts({
5206
+ T,
5207
+ peers,
5208
+ requests,
5209
+ loaded: data.loaded,
5210
+ activeId,
5211
+ sound: t.alertSound !== false,
5212
+ flash: t.alertFlash !== false
5213
+ });
4991
5214
  const callCtl = useCallController(me.userId, (peerId, video, direction) => {
4992
5215
  const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
4993
5216
  dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
@@ -5129,10 +5352,16 @@ ${peer.address}`
5129
5352
  { id: "recommended", icon: "sparkles", label: T.recommended },
5130
5353
  { id: "registered", icon: "at", label: T.registered },
5131
5354
  { id: "network", icon: "network", label: T.network },
5132
- { id: "apps", icon: "grid", label: T.apps },
5133
- { id: "profile", icon: "userRound", label: T.profile }
5355
+ { id: "apps", icon: "grid", label: T.apps }
5134
5356
  ];
5135
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement(DkBrowserNotice, { lang: t.lang }), /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr("") }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit, t, setTweak })), data.locked && /* @__PURE__ */ React.createElement(
5357
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("button", { onClick: () => setTab("profile"), title: T.profile, style: {
5358
+ padding: 4,
5359
+ borderRadius: 12,
5360
+ cursor: "pointer",
5361
+ display: "flex",
5362
+ border: "1px solid " + (tab === "profile" ? "var(--line)" : "transparent"),
5363
+ background: tab === "profile" ? "var(--panel-2)" : "transparent"
5364
+ } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement(DkBrowserNotice, { lang: t.lang }), /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", (nav.find((n) => n.id === tab) || { label: T.profile }).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr("") }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit, t, setTweak })), data.locked && /* @__PURE__ */ React.createElement(
5136
5365
  DkLockedOut,
5137
5366
  {
5138
5367
  compact: true,
@@ -90,31 +90,30 @@ export class EmbeddedHost {
90
90
  this.#messages = new MessageStore(resolve(opts.configDir, "messages.json"));
91
91
  this.#meta = new FriendMetaStore(resolve(opts.configDir, "friends-meta.json"));
92
92
  }
93
- /** Friend-list order: pinned first, then most recent conversation, then
94
- * never-messaged contacts by name. Returns a new array — `friends()`
95
- * hands back the SDK's own list and sorting it in place would reorder
96
- * the friend store as a side effect of merely rendering the sidebar. */
93
+ /** Friend-list order: pinned first, then most recent ACTIVITY, then by
94
+ * name. Activity is the newest of the last message, the friendship being
95
+ * accepted, and our request going out — so a friend you added or accepted
96
+ * a moment ago ranks exactly as if they had just written to you. The
97
+ * earlier rule sorted by last message alone and ranked new friends only
98
+ * among the never-messaged group at the bottom, which on a long list still
99
+ * put them below the fold and made the accept look like a no-op. Returns
100
+ * a new array — `friends()` hands back the SDK's own list and sorting it
101
+ * in place would reorder the friend store as a side effect of merely
102
+ * rendering the sidebar. */
97
103
  #sortForList(friends, last) {
98
104
  const rank = (f) => [
99
105
  this.#meta.get(f.carrierId)?.pinned ? 0 : 1,
100
- // Negated so a LARGER timestamp sorts first. No conversation →
101
- // MAX_SAFE_INTEGER, which parks those entries after every real one.
102
- // Not Infinity: two never-messaged friends would then compare
103
- // Infinity - Infinity = NaN, and the tie-break would only still work
104
- // by accident of NaN being falsy.
105
- last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
106
- // Among friends you have never messaged, NEWEST FRIENDSHIP first.
107
- // Alphabetical put someone you accepted ten seconds ago wherever their
108
- // name happened to land — usually far down a 50-name list, so accepting
109
- // a request looked like nothing had happened. The one friend you are
110
- // most likely to open is the one you just added.
111
- -(f.acceptedAt ?? 0),
106
+ // Negated so a LARGER timestamp sorts first. Nothing at all → 0, which
107
+ // parks the entry after every real one; a plain number, so two such
108
+ // entries compare 0 - 0 = 0 and fall through to the name (Infinity
109
+ // would give NaN there).
110
+ -Math.max(last.get(f.carrierId)?.ts ?? 0, f.acceptedAt ?? 0, f.requestedAt ?? 0),
112
111
  (f.name || f.carrierId).toLowerCase(),
113
112
  ];
114
113
  return [...friends].sort((a, b) => {
115
- const [ap, at, aa, an] = rank(a);
116
- const [bp, bt, ba, bn] = rank(b);
117
- return ap - bp || at - bt || aa - ba || an.localeCompare(bn);
114
+ const [ap, at, an] = rank(a);
115
+ const [bp, bt, bn] = rank(b);
116
+ return ap - bp || at - bt || an.localeCompare(bn);
118
117
  });
119
118
  }
120
119
  get downloadsDir() {
@@ -459,6 +458,10 @@ export class EmbeddedHost {
459
458
  f.status === "online" ||
460
459
  this.#messages.hasInbound(f.carrierId),
461
460
  lastSeen: f.acceptedAt,
461
+ // When WE sent the request. Counts as activity for the sidebar
462
+ // order: someone you just added belongs at the top while you
463
+ // wait for them, not filed by name at the bottom.
464
+ requestedAt: f.requestedAt,
462
465
  // The avatar they advertised over Carrier (userinfo `gender`).
463
466
  // Peer-to-peer, so it works for a friend who never registered a
464
467
  // beagles.eth name — until now that meant an identicon.
package/dist/server.js CHANGED
@@ -1975,11 +1975,19 @@ export function startBeagleServer(opts) {
1975
1975
  // be ordered, which is why the sidebar never reordered on new
1976
1976
  // activity.
1977
1977
  lastTs: lm?.ts ?? 0,
1978
- // When the friendship was established. The sidebar sorts by last
1979
- // message and falls back to this, so a friend you just accepted —
1980
- // who by definition has no messages — lands at the top of the
1981
- // no-history group instead of alphabetically halfway down it.
1978
+ // Which way the last message went. The UI's new-message alert
1979
+ // needs to know an inbound stamp moved, and inferring that from
1980
+ // the "you: " prefix on the preview is a convention, not a field.
1981
+ lastDir: lm?.dir ?? null,
1982
+ // When the friendship was established, and when we asked for it.
1983
+ // The sidebar ranks a friend by the newest of these and the last
1984
+ // message, so someone you just accepted — or just added — lands
1985
+ // at the top like a fresh message would, not in a no-history
1986
+ // group at the bottom. A daemon backend reports only lastSeen
1987
+ // (its acceptedAt); requestedAt is embedded-only and reads 0
1988
+ // elsewhere.
1982
1989
  acceptedAt: f.lastSeen ?? 0,
1990
+ requestedAt: f.requestedAt ?? 0,
1983
1991
  wire: "163",
1984
1992
  avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
1985
1993
  // What they advertised over Carrier wins over the directory: it is
@@ -2098,8 +2106,25 @@ export function startBeagleServer(opts) {
2098
2106
  return;
2099
2107
  }
2100
2108
  if (req.method === "GET" && url === "/api/chat-history") {
2101
- const peer = new URL(req.url || "/", "http://x").searchParams.get("peer") || undefined;
2102
- const r = await opts.call({ op: "chat-history", userid: peer });
2109
+ const q = new URL(req.url || "/", "http://x").searchParams;
2110
+ const peer = q.get("peer") || undefined;
2111
+ // A PAGE, never the whole thread. The UI re-polls the open
2112
+ // conversation every 2.5s, and this used to ask for everything each
2113
+ // time: a group with 2000 six-kilobyte bot reports is 12 MB of JSON
2114
+ // per poll per tab, which the daemon could not push over IPC inside
2115
+ // the 30s call timeout. While it tried, its socket stopped answering
2116
+ // the 400ms liveness probe, Beagle declared it gone and dropped the
2117
+ // backend, and the peer list came back empty — a blank chat panel
2118
+ // caused by one busy group. Default 200 is a full screen and then
2119
+ // some; `before` pages further back; the cap keeps a hand-written
2120
+ // URL from recreating the problem. Both `userid` and `peer` are sent:
2121
+ // the daemon reads the first, the embedded host the second, and with
2122
+ // only `userid` the embedded host returned every thread in the store.
2123
+ const lim = Number(q.get("limit") ?? 200);
2124
+ const limit = Number.isFinite(lim) && lim > 0 ? Math.min(lim, 1000) : 200;
2125
+ const bef = Number(q.get("before"));
2126
+ const before = Number.isFinite(bef) && bef > 0 ? bef : undefined;
2127
+ const r = await opts.call({ op: "chat-history", userid: peer, peer, before, limit });
2103
2128
  sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
2104
2129
  return;
2105
2130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",