@decentnetwork/beagle 0.1.111 → 0.1.113

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/dist/cli.js CHANGED
@@ -172,6 +172,7 @@ async function main() {
172
172
  doraRosterPath,
173
173
  downloadsDir: resolve(args.configDir, "downloads"),
174
174
  messagesPath: resolve(args.configDir, "messages.json"),
175
+ servicePeersPath: resolve(args.configDir, "service-peers-local.json"),
175
176
  callIce: {
176
177
  keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
177
178
  bootstrapNodes,
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.111";
1
+ window.__DK_UI_VERSION="0.1.113";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -68,8 +68,8 @@ window.__DK_UI_VERSION="0.1.111";
68
68
  speaker: '<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M16 9a3 3 0 0 1 0 6"/><path d="M19 7a6 6 0 0 1 0 10"/>',
69
69
  micFill: '<rect x="9" y="2.5" width="6" height="11.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5v3.5"/>'
70
70
  };
71
- function Icon({ name, size = 24, stroke = 1.75, color = "currentColor", fill = "none", style }) {
72
- const d = ICON_PATHS[name];
71
+ function Icon({ name, size = 24, stroke = 1.75, color = "currentColor", fill = "none", style, svgPath }) {
72
+ const d = svgPath || ICON_PATHS[name];
73
73
  if (!d)
74
74
  return null;
75
75
  return /* @__PURE__ */ React.createElement(
@@ -111,7 +111,10 @@ window.__DK_UI_VERSION="0.1.111";
111
111
  zap: '<path d="M13 2 4 13h7l-1 9 9-11h-7l1-9Z"/>',
112
112
  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
113
113
  bellOff: '<path d="M8.7 3.4A6 6 0 0 1 18 8.5c0 3 .5 5 1.1 6.3M6.1 6.1A6 6 0 0 0 6 8.5c0 6.5-2.5 8-2.5 8H16"/><path d="M13.7 20.5a2 2 0 0 1-3.4 0"/><path d="m3 3 18 18"/>',
114
- plug: '<path d="M9 2v6M15 2v6"/><path d="M7 8h10v3a5 5 0 0 1-10 0V8Z"/><path d="M12 16v6"/>'
114
+ plug: '<path d="M9 2v6M15 2v6"/><path d="M7 8h10v3a5 5 0 0 1-10 0V8Z"/><path d="M12 16v6"/>',
115
+ // A form with a filled-in field: the glyph for "Fill with Beagle" and any
116
+ // other host app that puts your identity into someone else's page.
117
+ form: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h5"/><path d="M8 16h3"/><path d="m14.5 17.5 1.6 1.6 3.4-3.6"/>'
115
118
  });
116
119
  var H = {};
117
120
  var hostNeedsWelcome = false;
@@ -119,8 +122,16 @@ window.__DK_UI_VERSION="0.1.111";
119
122
  var hostHasKeyBackup = false;
120
123
  var hostEnsWrite = true;
121
124
  var hostBrandHref = "";
125
+ var hostTabs = null;
126
+ var hostHideApps = null;
127
+ var hostApps = null;
128
+ var hostProfileSections = null;
122
129
  function setUiHost(host2) {
123
130
  H = host2 || {};
131
+ hostTabs = Array.isArray(H.hostTabs) ? H.hostTabs : null;
132
+ hostHideApps = Array.isArray(H.hideApps) ? H.hideApps : null;
133
+ hostApps = Array.isArray(H.hostApps) ? H.hostApps : null;
134
+ hostProfileSections = Array.isArray(H.hostProfileSections) ? H.hostProfileSections : null;
124
135
  hostNeedsWelcome = !!H.hostNeedsWelcome;
125
136
  hostPrefersRtcFile = !!H.hostPrefersRtcFile;
126
137
  hostHasKeyBackup = !!H.hostHasKeyBackup;
@@ -644,6 +655,18 @@ window.__DK_UI_VERSION="0.1.111";
644
655
  confirm: "list",
645
656
  scope: "assistant"
646
657
  },
658
+ {
659
+ name: "nodes-refresh",
660
+ usage: "/nodes refresh",
661
+ summary: "Look for friends that are provably service peers (the shipped exit table, this machine's dora roster) and offer to reclassify them. Nothing changes until you tick and confirm.",
662
+ params: {
663
+ type: "object",
664
+ properties: {},
665
+ required: []
666
+ },
667
+ confirm: "list",
668
+ scope: "assistant"
669
+ },
647
670
  {
648
671
  name: "find",
649
672
  usage: "/find <text>",
@@ -818,7 +841,8 @@ window.__DK_UI_VERSION="0.1.111";
818
841
  file: "service-peers.json",
819
842
  loader: "loadServicePeers()",
820
843
  rule: "A peer is a service peer if and only if its userid is in this list. The Assistant never infers it from a name or from having no messages. /friends excludes them entirely; /nodes is only them.",
821
- later: "A dora / ENS / bridge / directory service returns the same shape; the bundled file is v1."
844
+ later: "A dora / ENS / bridge / directory service returns the same shape; the bundled file is v1.",
845
+ refresh: "/nodes refresh asks GET /api/assistant/service-candidates for evidence-backed candidates and writes the ticked ones to this machine's overlay (POST /api/assistant/service-peers, stored at <configDir>/service-peers-local.json). The shipped list is never edited at runtime; the overlay wins on the same userid."
822
846
  }
823
847
  };
824
848
  var service_peers_default = {
@@ -973,6 +997,8 @@ window.__DK_UI_VERSION="0.1.111";
973
997
  const sub = (parts[1] || "").toLowerCase();
974
998
  if (sub === "list" || sub === "remove")
975
999
  return { ok: true, name: `${head}-${sub}`, args: parts.slice(2) };
1000
+ if (head === "nodes" && sub === "refresh")
1001
+ return { ok: true, name: "nodes-refresh", args: parts.slice(2) };
976
1002
  return { ok: false, name: head, args: parts.slice(1), error: `${head}-subcommand` };
977
1003
  }
978
1004
  return { ok: true, name: head, args: parts.slice(1) };
@@ -1010,6 +1036,19 @@ window.__DK_UI_VERSION="0.1.111";
1010
1036
  }
1011
1037
  return map;
1012
1038
  }
1039
+ function mergeServicePeers(bundled, local) {
1040
+ const byId = /* @__PURE__ */ new Map();
1041
+ for (const p of bundled && bundled.peers || [])
1042
+ if (p && p.userid)
1043
+ byId.set(p.userid, { ...p, source: "app" });
1044
+ for (const p of local && local.peers || [])
1045
+ if (p && p.userid)
1046
+ byId.set(p.userid, { ...p, source: "local" });
1047
+ return { version: 1, source: "merged", peers: [...byId.values()] };
1048
+ }
1049
+ function newCandidates(candidates, svcMap) {
1050
+ return (candidates != null ? candidates : []).filter((c) => c && c.userid && !(svcMap && svcMap.has(c.userid)));
1051
+ }
1013
1052
  function partitionPeers(friends, svcMap) {
1014
1053
  const people = [];
1015
1054
  const nodes = [];
@@ -1092,8 +1131,13 @@ window.__DK_UI_VERSION="0.1.111";
1092
1131
  return { match: partial[0], candidates: partial };
1093
1132
  return { match: null, candidates: partial };
1094
1133
  }
1095
- function loadServicePeers() {
1096
- return service_peers_default;
1134
+ async function loadServicePeers() {
1135
+ try {
1136
+ const local = await fetch("/api/assistant/service-peers").then((r) => r.json());
1137
+ return mergeServicePeers(service_peers_default, local);
1138
+ } catch (e) {
1139
+ return service_peers_default;
1140
+ }
1097
1141
  }
1098
1142
  var ASSISTANT_PEER = {
1099
1143
  id: ASSISTANT_ID,
@@ -1149,10 +1193,12 @@ window.__DK_UI_VERSION="0.1.111";
1149
1193
  const tail = lang === "zh" ? `\u7B5B\u9009\u6761\u4EF6\uFF08\u53EF\u7EC4\u5408\uFF0C\u53D6\u5E76\u96C6\uFF09\uFF1A
1150
1194
  ${filters}
1151
1195
 
1152
- \u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u4E0D\u4F1A\u88AB"\u4ECE\u672A/\u6C89\u9ED8"\u7C7B\u7B5B\u9009\u9009\u4E2D\u3002\u5220\u9664\u6C38\u8FDC\u9700\u8981\u5148\u786E\u8BA4\u3002` : `Filters (combine them; a friend matching any one is listed):
1196
+ \`/rename\`\u3001\`/mute\`\u3001\`/find\` \u5BF9\u597D\u53CB\u548C\u670D\u52A1\u8282\u70B9\u90FD\u9002\u7528\uFF1B\u53EA\u6709\u5217\u8868\u548C\u5220\u9664\u5206\u6210\u4E24\u8FB9\u3002
1197
+ \u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u4E0D\u4F1A\u88AB"\u4ECE\u672A/\u6C89\u9ED8"\u7C7B\u7B5B\u9009\u9009\u4E2D\uFF0C\u5E76\u4F1A\u5217\u51FA\u88AB\u8DF3\u8FC7\u7684\u540D\u5B57\u3002\u5220\u9664\u6C38\u8FDC\u9700\u8981\u5148\u786E\u8BA4\uFF0C\u53D6\u6D88\u52FE\u9009\u5373\u53EF\u4FDD\u7559\u3002` : `Filters (combine them; a friend matching any one is listed):
1153
1198
  ${filters}
1154
1199
 
1155
- Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters. Removal always asks first.`;
1200
+ \`/rename\`, \`/mute\` and \`/find\` work on friends and service peers alike \u2014 only listing and removing are split in two.
1201
+ Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters, and the ones held back are named. Removal always asks first: untick anyone you want to keep.`;
1156
1202
  return `${intro}
1157
1203
  ${cmds}
1158
1204
 
@@ -1166,8 +1212,9 @@ ${tail}`;
1166
1212
  time: (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
1167
1213
  text
1168
1214
  }, extra);
1169
- function AssistantPanel({ m, T, lang, onRemove }) {
1215
+ function AssistantPanel({ m, T, lang, onRemove, onAdopt }) {
1170
1216
  const rows = m.assistant.rows || [];
1217
+ const adopt = m.assistant.kind === "adopt";
1171
1218
  const [sel, setSel] = React.useState(() => new Set(rows.map((r) => r.userid)));
1172
1219
  const [busy, setBusy] = React.useState(false);
1173
1220
  const [done, setDone] = React.useState(null);
@@ -1178,7 +1225,7 @@ ${tail}`;
1178
1225
  n.has(id) ? n.delete(id) : n.add(id);
1179
1226
  return n;
1180
1227
  });
1181
- const label = lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
1228
+ const label = adopt ? lang === "zh" ? `\u52A0\u5165 ${sel.size} \u4E2A` : `Add ${sel.size} as ${sel.size === 1 ? "a node" : "nodes"}` : lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
1182
1229
  return /* @__PURE__ */ React.createElement("div", { style: { marginTop: 8, border: "1px solid var(--line)", borderRadius: 10, overflow: "hidden" } }, rows.map((r) => /* @__PURE__ */ React.createElement("label", { key: r.userid, style: {
1183
1230
  display: "flex",
1184
1231
  alignItems: "center",
@@ -1195,12 +1242,14 @@ ${tail}`;
1195
1242
  disabled: !!done || busy,
1196
1243
  onChange: () => toggle(r.userid)
1197
1244
  }
1198
- ), /* @__PURE__ */ React.createElement("span", { style: { flex: 1 } }, nameOf(r)), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, r.userid.slice(0, 8), "\u2026"))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "8px 10px" } }, done ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12.5, color: "var(--faint)" } }, done) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !sel.size, onClick: () => {
1245
+ ), /* @__PURE__ */ React.createElement("span", { style: { flex: 1 } }, nameOf(r), r.why && /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)", fontSize: 11.5 } }, ` \u2014 ${r.why}`)), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, r.userid.slice(0, 8), "\u2026"))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "8px 10px" } }, done ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12.5, color: "var(--faint)" } }, done) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: adopt ? "solid" : "danger", disabled: busy || !sel.size, onClick: () => {
1199
1246
  setBusy(true);
1200
- onRemove([...sel]).then((r) => {
1201
- setDone(lang === "zh" ? `\u5DF2\u5220\u9664 ${r.removed} \u4F4D${r.failed ? `\uFF0C${r.failed} \u4F4D\u5931\u8D25` : ""}` : `Removed ${r.removed}${r.failed ? `, ${r.failed} failed` : ""}`);
1247
+ const picked = rows.filter((r) => sel.has(r.userid));
1248
+ const run = adopt ? onAdopt(picked) : onRemove([...sel]);
1249
+ run.then((r) => {
1250
+ setDone(adopt ? lang === "zh" ? `\u5DF2\u52A0\u5165 ${r.added} \u4E2A\u670D\u52A1\u8282\u70B9` : `Added ${r.added} as service peers` : lang === "zh" ? `\u5DF2\u5220\u9664 ${r.removed} \u4F4D${r.failed ? `\uFF0C${r.failed} \u4F4D\u5931\u8D25` : ""}` : `Removed ${r.removed}${r.failed ? `, ${r.failed} failed` : ""}`);
1202
1251
  }).finally(() => setBusy(false));
1203
- } }, busy ? T.working || "Working\u2026" : label), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, color: "var(--faint)" } }, lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u53EF\u4FDD\u7559" : "Untick anyone you want to keep"))));
1252
+ } }, busy ? T.working || "Working\u2026" : label), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, color: "var(--faint)" } }, adopt ? lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u5373\u4FDD\u6301\u4E3A\u597D\u53CB" : "Untick any you want to keep as a friend" : lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u53EF\u4FDD\u7559" : "Untick anyone you want to keep"))));
1204
1253
  }
1205
1254
  function useAssistant({ lang, T, muted, onToggleMute, sound, flash, setAlert, onReloadFriends }) {
1206
1255
  const [thread, setThread] = React.useState(() => [{ day: (/* @__PURE__ */ new Date()).toLocaleDateString() }, asMsg("them", helpText(lang))]);
@@ -1210,11 +1259,13 @@ ${tail}`;
1210
1259
  }, [lang]);
1211
1260
  const fetchFriends = async () => {
1212
1261
  const r = await fetch("/api/assistant/friends").then((x) => x.json());
1213
- const { people, nodes } = partitionPeers(r.friends || [], servicePeerMap(loadServicePeers()));
1262
+ const svcMap = servicePeerMap(await loadServicePeers());
1263
+ const { people, nodes } = partitionPeers(r.friends || [], svcMap);
1214
1264
  return {
1215
1265
  friends: people,
1216
1266
  nodes,
1217
1267
  all: r.friends || [],
1268
+ svcMap,
1218
1269
  historySince: r.historySince || null,
1219
1270
  now: r.now || Date.now(),
1220
1271
  ver: r.beagleVer || ""
@@ -1281,6 +1332,33 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1281
1332
  - Waiting to be accepted: ${pending}
1282
1333
  - History on this device since: ${fmtDate(d.historySince, lang)}
1283
1334
  - Version: ${d.ver}`));
1335
+ }
1336
+ case "nodes-refresh": {
1337
+ const d = await fetchFriends();
1338
+ const r = await fetch("/api/assistant/service-candidates").then((x) => x.json());
1339
+ const fresh = newCandidates(r.candidates || [], d.svcMap);
1340
+ const src = r.sources || {};
1341
+ const where = lang === "zh" ? `\u67E5\u8BE2\u6765\u6E90\uFF1A\u5185\u7F6E\u51FA\u53E3\u8868\uFF08${src.exits || 0} \u6761\uFF09${src.dora == null ? "\uFF0C\u672C\u673A\u4E0D\u662F dora" : `\uFF0C\u672C\u673A dora \u540D\u518C\uFF08${src.dora} \u6761\uFF09`}` : `Looked at: the shipped exit table (${src.exits || 0})${src.dora == null ? ", and this machine is not a dora" : `, and this machine's dora roster (${src.dora})`}`;
1342
+ if (!fresh.length) {
1343
+ return append(asMsg("them", lang === "zh" ? `**\u6CA1\u6709\u65B0\u7684\u670D\u52A1\u8282\u70B9**
1344
+
1345
+ \u73B0\u6709 ${d.nodes.length} \u4E2A\u5DF2\u8BC6\u522B\u3002${where}\u3002
1346
+
1347
+ *\u53EA\u91C7\u7528\u8BC1\u636E\uFF0C\u4E0D\u9760\u540D\u5B57\u731C\u6D4B\u3002*` : `**Nothing new**
1348
+
1349
+ ${d.nodes.length} service peers are already classified. ${where}.
1350
+
1351
+ *Evidence only \u2014 names are never guessed from.*`));
1352
+ }
1353
+ const title = lang === "zh" ? `**\u53D1\u73B0 ${fresh.length} \u4E2A\u53EF\u4EE5\u6807\u4E3A\u670D\u52A1\u8282\u70B9\u7684\u597D\u53CB** \u2014 \u52FE\u9009\u540E\u624D\u4F1A\u751F\u6548
1354
+
1355
+ ${where}\u3002` : `**${fresh.length === 1 ? "1 friend looks" : `${fresh.length} friends look`} like service peers** \u2014 nothing changes until you tick and confirm
1356
+
1357
+ ${where}.`;
1358
+ const lines = fresh.map((c) => `- **${c.name}** \`${c.kind}${c.role ? " \xB7 " + c.role : ""}\` \u2014 ${c.why}`).join("\n");
1359
+ return append(asMsg("them", `${title}
1360
+
1361
+ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
1284
1362
  }
1285
1363
  case "nodes-list":
1286
1364
  case "friends-list": {
@@ -1370,6 +1448,24 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1370
1448
  return append(asMsg("them", lang === "zh" ? `\u51FA\u9519\u4E86\uFF1A${e && e.message || e}` : `That failed: ${e && e.message || e}`));
1371
1449
  }
1372
1450
  }, [append, lang, muted, onToggleMute, setAlert, onReloadFriends]);
1451
+ const adoptServicePeers = React.useCallback(async (rows) => {
1452
+ const add = rows.map((r) => ({ userid: r.userid, name: r.name, kind: r.kind, role: r.role }));
1453
+ let added = 0;
1454
+ try {
1455
+ const r = await fetch("/api/assistant/service-peers", {
1456
+ method: "POST",
1457
+ headers: { "content-type": "application/json" },
1458
+ body: JSON.stringify({ add })
1459
+ }).then((x) => x.json());
1460
+ added = r && r.ok ? add.length : 0;
1461
+ } catch (e) {
1462
+ added = 0;
1463
+ }
1464
+ if (onReloadFriends)
1465
+ onReloadFriends();
1466
+ append(asMsg("them", added ? lang === "zh" ? `\u5DF2\u628A ${added} \u4F4D\u6807\u4E3A\u670D\u52A1\u8282\u70B9\uFF0C\u5B83\u4EEC\u4E0D\u518D\u51FA\u73B0\u5728 \`/friends\` \u4E2D\u3002` : `${added} now count as service peers \u2014 they no longer appear in \`/friends\`.` : lang === "zh" ? "\u5199\u5165\u5931\u8D25\uFF0C\u6CA1\u6709\u4EFB\u4F55\u6539\u52A8\u3002" : "That write failed; nothing changed."));
1467
+ return { added };
1468
+ }, [append, lang, onReloadFriends]);
1373
1469
  const removeFriends = React.useCallback(async (ids) => {
1374
1470
  let removed = 0;
1375
1471
  const failed = [];
@@ -1389,7 +1485,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1389
1485
  append(asMsg("them", failed.length ? lang === "zh" ? `\u5DF2\u5220\u9664 ${removed} \u4F4D\uFF0C${failed.length} \u4F4D\u5931\u8D25\u3002` : `Removed ${removed}, ${failed.length} failed.` : lang === "zh" ? `\u5DF2\u5220\u9664 ${removed} \u4F4D\u597D\u53CB\u3002` : `Removed ${removed} friend${removed === 1 ? "" : "s"}.`));
1390
1486
  return { removed, failed: failed.length };
1391
1487
  }, [append, lang, onReloadFriends]);
1392
- return { thread, handle, removeFriends };
1488
+ return { thread, handle, removeFriends, adoptServicePeers };
1393
1489
  }
1394
1490
  var DK_CMD_HIST_KEY = "beagle.assistant.history";
1395
1491
  var DK_CMD_HIST_MAX = 50;
@@ -2196,7 +2292,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2196
2292
  return null;
2197
2293
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
2198
2294
  }
2199
- function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, answered, selMode, selected, onToggleSel, busy, isGroup }) {
2295
+ function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, onAssistantAdopt, answered, selMode, selected, onToggleSel, busy, isGroup }) {
2200
2296
  const mine = m.from === "me";
2201
2297
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
2202
2298
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -2525,7 +2621,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2525
2621
  letterSpacing: -0.1,
2526
2622
  wordBreak: "break-word"
2527
2623
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
2528
- })(), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), m.assistant && onAssistantRemove && /* @__PURE__ */ React.createElement(AssistantPanel, { m, T, lang, onRemove: onAssistantRemove }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), (() => {
2624
+ })(), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), m.assistant && onAssistantRemove && /* @__PURE__ */ React.createElement(AssistantPanel, { m, T, lang, onRemove: onAssistantRemove, onAdopt: onAssistantAdopt }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), (() => {
2529
2625
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
2530
2626
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
2531
2627
  if (!via)
@@ -2570,7 +2666,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2570
2666
  ) : mine && m.via === "offline" && m.status !== "queued" && m.status !== "sending" ? /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 2.2, color: "#f59e0b", title: T.viaOutOffline || "posted to the offline relay \u2014 they get it when they come online" }) : mine && m.status === "sent" && m.confirmed === false ? /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 2.2, color: "var(--faint)", title: T.sentUnconfirmed || "sent \u2014 not confirmed by the peer" }) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
2571
2667
  );
2572
2668
  }
2573
- function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted, onToggleMute, phone, onBack }) {
2669
+ function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted, onToggleMute, phone, onBack }) {
2574
2670
  const scrollRef = React.useRef(null);
2575
2671
  const prependRef = React.useRef(null);
2576
2672
  const [earlierBusy, setEarlierBusy] = React.useState(false);
@@ -2933,7 +3029,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2933
3029
  // The internal id is not an address anyone can use and not a name
2934
3030
  // anyone chose; saying what this row IS is more use than showing it.
2935
3031
  /* @__PURE__ */ React.createElement("div", { style: { marginTop: 2, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, T && T.assistantSub || "Local \xB7 APP")
2936
- ) : phone ? /* @__PURE__ */ React.createElement("div", { style: { marginTop: 2, display: "flex" } }, /* @__PURE__ */ React.createElement(RouteTag, { peer })) : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), title: T && T.openNet || "Network view", style: { background: "none", border: "none", cursor: "pointer", padding: 0, color: "var(--accent)", display: "inline-flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.ip, title: peer.ip }, peer.ip), peer.address && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement(
3032
+ ) : phone ? /* @__PURE__ */ React.createElement("div", { style: { marginTop: 2, display: "flex" } }, /* @__PURE__ */ React.createElement(RouteTag, { peer })) : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), onOpenNet && /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), onOpenNet && /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), title: T && T.openNet || "Network view", style: { background: "none", border: "none", cursor: "pointer", padding: 0, color: "var(--accent)", display: "inline-flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.ip, title: peer.ip }, peer.ip), peer.address && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement(
2937
3033
  Mono,
2938
3034
  {
2939
3035
  size: 11.5,
@@ -2968,7 +3064,7 @@ ${peer.address}`
2968
3064
  } }), phone && onSendRtcFile && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "file", label: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => {
2969
3065
  setMenu(false);
2970
3066
  rtcFileRef.current && rtcFileRef.current.click();
2971
- } }), phone && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
3067
+ } }), phone && !peer.app && onOpenNet && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
2972
3068
  setMenu(false);
2973
3069
  onOpenNet(peer);
2974
3070
  } }), !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
@@ -3003,6 +3099,7 @@ ${peer.address}`
3003
3099
  isGroup,
3004
3100
  onTheater: setTheater,
3005
3101
  onAssistantRemove,
3102
+ onAssistantAdopt,
3006
3103
  onDelete: (id) => doDelete([id]),
3007
3104
  onCancel: doCancel,
3008
3105
  onRetry: doRetry,
@@ -3162,12 +3259,12 @@ ${peer.address}`
3162
3259
  function ChatEmpty({ T }) {
3163
3260
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--faint)", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "message", size: 40, stroke: 1.4, color: "var(--line)" }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13 } }, T.pickPeer));
3164
3261
  }
3165
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, prefillAddr, onPrefillConsumed, muted, onToggleMute, phone, onBack }) {
3262
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, prefillAddr, onPrefillConsumed, muted, onToggleMute, phone, onBack }) {
3166
3263
  const peer = peers.find((p) => p.id === activeId);
3167
3264
  if (phone) {
3168
- return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted: !!muted && muted.includes(peer.id), onToggleMute, phone: true, onBack }) : /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted, wide: true });
3265
+ return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted: !!muted && muted.includes(peer.id), onToggleMute, phone: true, onBack }) : /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted, wide: true });
3169
3266
  }
3170
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
3267
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
3171
3268
  }
3172
3269
  Object.assign(window, { ChatTab });
3173
3270
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -3940,7 +4037,7 @@ ${peer.address}`
3940
4037
  return /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) });
3941
4038
  return /* @__PURE__ */ React.createElement("div", { onClick: onClose, style: { position: "fixed", inset: 0, zIndex: 90, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 } }, /* @__PURE__ */ React.createElement("div", { onClick: (e) => e.stopPropagation(), style: { width: 360, maxWidth: "100%", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: 16, display: "flex", flexDirection: "column", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, marginBottom: 4 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 40, radius: 10, dot: false }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 700, color: "var(--text)" } }, zh ? "\u66F4\u6362\u5934\u50CF" : "Change avatar"), /* @__PURE__ */ React.createElement(Btn, { icon: "x", size: "sm", onClick: onClose })), item("sparkles", zh ? "\u9009\u4E00\u4E2A CryptoPunk" : "Choose a CryptoPunk", () => setPicking(true)), canUpload && item("image", zh ? "\u4E0A\u4F20\u7167\u7247" : "Upload a photo", () => fileRef.current && fileRef.current.click()), has && item("trash", zh ? "\u79FB\u9664\u5934\u50CF" : "Remove avatar", () => save(canUpload ? { punkId: null, avatarDataUrl: null } : { punkId: null }), true), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onFile, style: { display: "none" } }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)", lineHeight: 1.5, marginTop: 2 } }, zh ? "\u4E0D\u9700\u8981\u6CE8\u518C beagles.eth \u540D\u5B57\u3002\u597D\u53CB\u4F1A\u5728\u4E0B\u6B21\u6536\u5230\u4F60\u7684\u8D44\u6599\u65F6\u770B\u5230\u65B0\u5934\u50CF\u3002" : "No beagles.eth name needed. Friends see it the next time your profile reaches them."), err && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--danger)" } }, err)));
3942
4039
  }
3943
- function ProfileTab({ T, me, onEdit, t, setTweak, onSetAvatar }) {
4040
+ function ProfileTab({ T, me, onEdit, t, setTweak, onSetAvatar, sections, sectionProps }) {
3944
4041
  var _a;
3945
4042
  const [avatarSheet, setAvatarSheet] = React.useState(false);
3946
4043
  const [qr, setQr] = React.useState(null);
@@ -3972,12 +4069,33 @@ ${peer.address}`
3972
4069
  },
3973
4070
  style: { width: 16, height: 16, accentColor: "var(--accent)" }
3974
4071
  }
3975
- ), /* @__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) }), avatarSheet && /* @__PURE__ */ React.createElement(DkAvatarSheet, { T, me, zh: t && t.lang === "zh", onSet: onSetAvatar, onClose: () => setAvatarSheet(false) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
4072
+ ), /* @__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)))), Array.isArray(sections) && sections.length > 0 && /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "0 20px 40px", display: "flex", flexDirection: "column", gap: 16 } }, sections.map((sec) => sec && typeof sec.render === "function" ? /* @__PURE__ */ React.createElement(DkHostSection, { key: sec.id, sec, props: sectionProps, lang: t && t.lang }) : null)), qr && /* @__PURE__ */ React.createElement(DkQrModal, { T, value: qr.value, label: qr.label, onClose: () => setQr(null) }), avatarSheet && /* @__PURE__ */ React.createElement(DkAvatarSheet, { T, me, zh: t && t.lang === "zh", onSet: onSetAvatar, onClose: () => setAvatarSheet(false) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
3976
4073
  if (onEdit)
3977
4074
  onEdit(name, description);
3978
4075
  setEditing(false);
3979
4076
  } }));
3980
4077
  }
4078
+ var DkHostSection = class extends React.Component {
4079
+ constructor(props) {
4080
+ super(props);
4081
+ this.state = { err: null };
4082
+ }
4083
+ static getDerivedStateFromError(err) {
4084
+ return { err };
4085
+ }
4086
+ render() {
4087
+ var _a;
4088
+ const { sec, props, lang } = this.props;
4089
+ if (this.state.err) {
4090
+ return /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, lang === "zh" ? "\u8FD9\u4E2A\u9644\u52A0\u677F\u5757\u51FA\u9519\u4E86\u3002" : "This host section failed to render.");
4091
+ }
4092
+ try {
4093
+ return (_a = sec.render(props)) != null ? _a : null;
4094
+ } catch (e) {
4095
+ return null;
4096
+ }
4097
+ }
4098
+ };
3981
4099
  Object.assign(window, { DkShareSheet, ProfileTab, DkPunkAvatar, DkEnsAvatar, DkPublicProfile });
3982
4100
  var DK_PHONE_MQ = "(max-width: 700px)";
3983
4101
  function useIsPhone() {
@@ -4191,6 +4309,82 @@ ${peer.address}`
4191
4309
  return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px", ...style } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 1, textTransform: "uppercase", color: "var(--faint)" } }, label), count != null && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "var(--faint)" } }, count), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, height: 1, background: "var(--line)" } }), trailing);
4192
4310
  }
4193
4311
  Object.assign(window, { useIsPhone, DkPhoneStyles, DkIdenticon, DkBot, DkAvatar, DkImgAvatar, StatusDot, Mono, shortKey, RouteTag, Tag, Unread, Btn, Section });
4312
+ var BUILTIN_TABS = ["chat", "people", "network", "apps", "profile"];
4313
+ var warned = /* @__PURE__ */ new Set();
4314
+ function warnOnce(what) {
4315
+ if (warned.has(what))
4316
+ return;
4317
+ warned.add(what);
4318
+ try {
4319
+ console.warn(`[beagle-ui] ignoring unknown host tab or app: ${what}`);
4320
+ } catch (e) {
4321
+ }
4322
+ }
4323
+ function pickText(value, lang, fallback = "") {
4324
+ if (typeof value === "string")
4325
+ return value;
4326
+ if (value && typeof value === "object")
4327
+ return value[lang] || value.en || value.zh || fallback;
4328
+ return fallback;
4329
+ }
4330
+ function resolveTabs(hostTabs2) {
4331
+ if (!Array.isArray(hostTabs2) || !hostTabs2.length) {
4332
+ return BUILTIN_TABS.map((id) => ({ id, kind: "builtin" }));
4333
+ }
4334
+ const out = [];
4335
+ const seen = /* @__PURE__ */ new Set();
4336
+ for (const entry of hostTabs2) {
4337
+ if (typeof entry === "string") {
4338
+ if (!BUILTIN_TABS.includes(entry)) {
4339
+ warnOnce(entry);
4340
+ continue;
4341
+ }
4342
+ if (seen.has(entry))
4343
+ continue;
4344
+ seen.add(entry);
4345
+ out.push({ id: entry, kind: "builtin" });
4346
+ continue;
4347
+ }
4348
+ if (entry && typeof entry === "object" && typeof entry.id === "string" && entry.id) {
4349
+ if (typeof entry.render !== "function") {
4350
+ warnOnce(`${entry.id} (no render function)`);
4351
+ continue;
4352
+ }
4353
+ if (BUILTIN_TABS.includes(entry.id)) {
4354
+ warnOnce(`${entry.id} (a built-in id cannot be redefined)`);
4355
+ continue;
4356
+ }
4357
+ if (seen.has(entry.id))
4358
+ continue;
4359
+ seen.add(entry.id);
4360
+ out.push({
4361
+ id: entry.id,
4362
+ kind: "host",
4363
+ label: entry.label,
4364
+ icon: entry.icon,
4365
+ render: entry.render,
4366
+ badge: typeof entry.badge === "function" ? entry.badge : void 0
4367
+ });
4368
+ continue;
4369
+ }
4370
+ warnOnce(String(entry && entry.id ? entry.id : entry));
4371
+ }
4372
+ return out.length ? out : BUILTIN_TABS.map((id) => ({ id, kind: "builtin" }));
4373
+ }
4374
+ function hasTab(tabs, id) {
4375
+ return (tabs != null ? tabs : []).some((t) => t.id === id);
4376
+ }
4377
+ function resolveApps(builtins, hideApps, hostApps2) {
4378
+ const hidden = new Set(Array.isArray(hideApps) ? hideApps : []);
4379
+ const host2 = (Array.isArray(hostApps2) ? hostApps2 : []).filter((a) => {
4380
+ if (a && typeof a.id === "string" && a.id && typeof a.open === "function")
4381
+ return true;
4382
+ warnOnce(`app ${a && a.id ? a.id : String(a)}`);
4383
+ return false;
4384
+ }).map((a) => ({ ...a, host: true }));
4385
+ const kept = (builtins != null ? builtins : []).filter((a) => !hidden.has(a.id));
4386
+ return host2.concat(kept);
4387
+ }
4194
4388
  var APPS = [
4195
4389
  {
4196
4390
  id: "meet",
@@ -4230,11 +4424,24 @@ ${peer.address}`
4230
4424
  const b64 = btoa(unescape(encodeURIComponent(json)));
4231
4425
  return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4232
4426
  }
4233
- function AppsTab({ T, t }) {
4427
+ function AppsTab({ T, t, me, peers, phone, setTab }) {
4234
4428
  const zh = t && t.lang === "zh";
4429
+ const apps = React.useMemo(
4430
+ () => resolveApps(APPS, hostHideApps, hostApps),
4431
+ []
4432
+ );
4235
4433
  const [busy, setBusy] = React.useState(null);
4236
4434
  const [err, setErr] = React.useState(null);
4237
4435
  const open = async (app) => {
4436
+ if (app.host) {
4437
+ setErr(null);
4438
+ try {
4439
+ app.open({ T, t, me, peers, phone, setTab });
4440
+ } catch (e) {
4441
+ setErr(String(e && e.message || e));
4442
+ }
4443
+ return;
4444
+ }
4238
4445
  setBusy(app.id);
4239
4446
  setErr(null);
4240
4447
  const win = window.open("", "_blank");
@@ -4256,7 +4463,7 @@ ${peer.address}`
4256
4463
  setBusy(null);
4257
4464
  }
4258
4465
  };
4259
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 16 } }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: "var(--faint)" } }, zh ? "\u5E94\u7528" : "Apps"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 6, lineHeight: 1.6 } }, zh ? "\u8FD9\u4E9B\u5E94\u7528\u63A5\u53D7 Beagle \u8EAB\u4EFD\u3002\u4ECE\u8FD9\u91CC\u6253\u5F00\u4E0D\u9700\u8981\u518D\u767B\u5F55\u4E00\u6B21 \u2014\u2014 \u4F1A\u5E26\u4E0A\u4E00\u4E2A\u4E24\u5206\u949F\u5185\u6709\u6548\u7684\u7B7E\u540D\u3002" : "These accept a Beagle identity. Opening one from here does not ask you to sign in again \u2014 it carries a signature that is good for two minutes.")), err && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "#f85149" } }, err), APPS.map((app) => /* @__PURE__ */ React.createElement("div", { key: app.id, style: {
4466
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 16 } }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: "var(--faint)" } }, zh ? "\u5E94\u7528" : "Apps"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 6, lineHeight: 1.6 } }, zh ? "\u8FD9\u4E9B\u5E94\u7528\u63A5\u53D7 Beagle \u8EAB\u4EFD\u3002\u4ECE\u8FD9\u91CC\u6253\u5F00\u4E0D\u9700\u8981\u518D\u767B\u5F55\u4E00\u6B21 \u2014\u2014 \u4F1A\u5E26\u4E0A\u4E00\u4E2A\u4E24\u5206\u949F\u5185\u6709\u6548\u7684\u7B7E\u540D\u3002" : "These accept a Beagle identity. Opening one from here does not ask you to sign in again \u2014 it carries a signature that is good for two minutes.")), err && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "#f85149" } }, err), apps.map((app) => /* @__PURE__ */ React.createElement("div", { key: app.id, style: {
4260
4467
  display: "flex",
4261
4468
  alignItems: "center",
4262
4469
  gap: 14,
@@ -4273,7 +4480,7 @@ ${peer.address}`
4273
4480
  display: "flex",
4274
4481
  alignItems: "center",
4275
4482
  justifyContent: "center"
4276
- } }, /* @__PURE__ */ React.createElement(Icon, { name: app.icon, size: 19, color: "var(--accent)", stroke: 1.8 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 14, fontWeight: 600, color: "var(--text)" } }, app.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 2 } }, zh ? app.blurb.zh : app.blurb.en), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--dim)", marginTop: 3 } }, app.origin.replace(/^https?:\/\//, ""))), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", onClick: busy ? void 0 : () => open(app) }, busy === app.id ? zh ? "\u6253\u5F00\u4E2D\u2026" : "opening\u2026" : zh ? "\u6253\u5F00" : "Open")))));
4483
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: app.icon, size: 19, color: "var(--accent)", stroke: 1.8 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 14, fontWeight: 600, color: "var(--text)" } }, pickText(app.name, t && t.lang, app.id)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 2 } }, pickText(app.blurb, t && t.lang, "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--dim)", marginTop: 3 } }, app.host ? zh ? "\u672C\u673A\u5E94\u7528" : "runs in this app" : String(app.origin || "").replace(/^https?:\/\//, ""))), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", onClick: busy ? void 0 : () => open(app) }, busy === app.id ? zh ? "\u6253\u5F00\u4E2D\u2026" : "opening\u2026" : zh ? "\u6253\u5F00" : "Open")))));
4277
4484
  }
4278
4485
  var DK_FALLBACK_ICE_SERVERS = [
4279
4486
  { urls: "stun:stun.l.google.com:19302" },
@@ -6262,7 +6469,7 @@ ${peer.address}`
6262
6469
  meetingDesc: "\u8DE8\u670D\u52A1\u5668\u521B\u5EFA\u97F3\u89C6\u9891\u623F\u95F4\uFF0C\u901A\u8FC7\u865A\u62DF IP \u9080\u8BF7\u597D\u53CB\u3002\u7FA4\u7EC4\u901A\u8BDD\u3001\u5C4F\u5E55\u5171\u4EAB\u4E0E\u5171\u4EAB\u51FA\u53E3\u8DEF\u7531\u5C06\u5728\u4E0B\u4E2A\u7248\u672C\u4E0A\u7EBF\u3002"
6263
6470
  }
6264
6471
  };
6265
- function RailBtn({ icon, label, active, soon, onClick }) {
6472
+ function RailBtn({ icon, label, active, soon, onClick, svgPath }) {
6266
6473
  return /* @__PURE__ */ React.createElement("button", { onClick, title: label, style: {
6267
6474
  width: 52,
6268
6475
  height: 52,
@@ -6277,9 +6484,9 @@ ${peer.address}`
6277
6484
  alignItems: "center",
6278
6485
  justifyContent: "center",
6279
6486
  gap: 3
6280
- } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, size: 20, stroke: active ? 2.1 : 1.8, color: active ? "var(--accent)" : "var(--dim)" }), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 9, fontWeight: 600, letterSpacing: 0.2, color: active ? "var(--accent)" : "var(--faint)" } }, label), soon && /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", top: 5, right: 6, width: 6, height: 6, borderRadius: 999, background: "var(--warn)" } }));
6487
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, svgPath, size: 20, stroke: active ? 2.1 : 1.8, color: active ? "var(--accent)" : "var(--dim)" }), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 9, fontWeight: 600, letterSpacing: 0.2, color: active ? "var(--accent)" : "var(--faint)" } }, label), soon && /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", top: 5, right: 6, width: 6, height: 6, borderRadius: 999, background: "var(--warn)" } }));
6281
6488
  }
6282
- function TabBarBtn({ icon, label, active, badge, onClick }) {
6489
+ function TabBarBtn({ icon, label, active, badge, onClick, svgPath }) {
6283
6490
  return /* @__PURE__ */ React.createElement("button", { onClick, style: {
6284
6491
  flex: 1,
6285
6492
  minWidth: 0,
@@ -6294,9 +6501,36 @@ ${peer.address}`
6294
6501
  justifyContent: "center",
6295
6502
  gap: 3,
6296
6503
  fontFamily: "var(--mono)"
6297
- } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, size: 22, stroke: active ? 2.1 : 1.8, color: active ? "var(--accent)" : "var(--dim)" }), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 10, fontWeight: 600, color: active ? "var(--accent)" : "var(--faint)", whiteSpace: "nowrap" } }, label), badge ? /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", top: 4, left: "50%", marginLeft: 6 } }, /* @__PURE__ */ React.createElement(Unread, { n: badge })) : null);
6504
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, svgPath, size: 22, stroke: active ? 2.1 : 1.8, color: active ? "var(--accent)" : "var(--dim)" }), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 10, fontWeight: 600, color: active ? "var(--accent)" : "var(--faint)", whiteSpace: "nowrap" } }, label), badge ? /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", top: 4, left: "50%", marginLeft: 6 } }, /* @__PURE__ */ React.createElement(Unread, { n: badge })) : null);
6298
6505
  }
6299
6506
  var DK_PEOPLE_TABS = ["here", "recommended", "registered"];
6507
+ var HostTabPane = class extends React.Component {
6508
+ constructor(props) {
6509
+ super(props);
6510
+ this.state = { err: null };
6511
+ }
6512
+ static getDerivedStateFromError(err) {
6513
+ return { err };
6514
+ }
6515
+ componentDidCatch(err) {
6516
+ try {
6517
+ console.error("[beagle-ui] host tab failed", err);
6518
+ } catch (e) {
6519
+ }
6520
+ }
6521
+ render() {
6522
+ var _a;
6523
+ const { tab, props, lang } = this.props;
6524
+ if (this.state.err) {
6525
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 24, textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--faint)", maxWidth: 420 } }, lang === "zh" ? `"${pickText(tab.label, lang, tab.id)}" \u8FD9\u4E2A\u6807\u7B7E\u9875\u51FA\u9519\u4E86\u3002\u5176\u5B83\u6807\u7B7E\u9875\u4E0D\u53D7\u5F71\u54CD\u3002` : `The "${pickText(tab.label, lang, tab.id)}" tab failed to render. The rest of the app is unaffected.`));
6526
+ }
6527
+ try {
6528
+ return (_a = tab.render(props)) != null ? _a : null;
6529
+ } catch (e) {
6530
+ return null;
6531
+ }
6532
+ }
6533
+ };
6300
6534
  function Brand({ T, children, style }) {
6301
6535
  if (!hostBrandHref)
6302
6536
  return /* @__PURE__ */ React.createElement("span", { style }, children);
@@ -6413,7 +6647,17 @@ ${peer.address}`
6413
6647
  }
6414
6648
  function DkApp() {
6415
6649
  const [t, setTweak] = useTweaks({ ...DK_DEFAULTS, lang: dkDetectLang() });
6416
- const [tab, setTab] = React.useState(t.startTab || "chat");
6650
+ const tabs = React.useMemo(() => resolveTabs(hostTabs), []);
6651
+ const showPeople = hasTab(tabs, "people");
6652
+ const showNetwork = hasTab(tabs, "network");
6653
+ const showProfile = hasTab(tabs, "profile");
6654
+ const firstTab = tabs[0] ? tabs[0].id === "people" ? "here" : tabs[0].id : "chat";
6655
+ const [tab, setTab] = React.useState(() => {
6656
+ const want = t.startTab || "chat";
6657
+ if (want === "chat" && hasTab(tabs, "chat"))
6658
+ return "chat";
6659
+ return hasTab(tabs, want) || DK_PEOPLE_TABS.includes(want) && showPeople ? want : firstTab;
6660
+ });
6417
6661
  const [activeId, setActiveId] = React.useState(null);
6418
6662
  const data = useDaemonData();
6419
6663
  const me = data.me;
@@ -6448,11 +6692,13 @@ ${peer.address}`
6448
6692
  });
6449
6693
  }, []);
6450
6694
  React.useEffect(() => {
6695
+ if (!showNetwork)
6696
+ return void 0;
6451
6697
  refreshBackend();
6452
6698
  const ms = backend && backend.state === "releasing" ? 1500 : 8e3;
6453
6699
  const t2 = setInterval(refreshBackend, ms);
6454
6700
  return () => clearInterval(t2);
6455
- }, [refreshBackend, backend && backend.state]);
6701
+ }, [refreshBackend, backend && backend.state, showNetwork]);
6456
6702
  const armLan = React.useCallback(async () => {
6457
6703
  await dkPost("/api/lan-arm", {});
6458
6704
  refreshBackend();
@@ -6721,29 +6967,61 @@ ${peer.address}`
6721
6967
  setTab("chat");
6722
6968
  openConversation(id);
6723
6969
  };
6724
- const onOpenNet = () => setTab("network");
6725
- const nav = [
6726
- { id: "chat", icon: "message", label: T.chat },
6727
- { id: "here", icon: "users", label: T.here },
6728
- { id: "recommended", icon: "sparkles", label: T.recommended },
6729
- { id: "registered", icon: "at", label: T.registered },
6730
- { id: "network", icon: "network", label: T.network },
6731
- { id: "apps", icon: "grid", label: T.apps }
6732
- ];
6970
+ const onOpenNet = showNetwork ? () => setTab("network") : null;
6971
+ const RAIL_FOR = {
6972
+ chat: [{ id: "chat", icon: "message", label: T.chat }],
6973
+ people: [
6974
+ { id: "here", icon: "users", label: T.here },
6975
+ { id: "recommended", icon: "sparkles", label: T.recommended },
6976
+ { id: "registered", icon: "at", label: T.registered }
6977
+ ],
6978
+ network: [{ id: "network", icon: "network", label: T.network }],
6979
+ apps: [{ id: "apps", icon: "grid", label: T.apps }],
6980
+ profile: []
6981
+ };
6982
+ const nav = tabs.flatMap((x) => x.kind === "builtin" ? RAIL_FOR[x.id] || [] : [{ id: x.id, icon: x.icon, label: pickText(x.label, t.lang, x.id) }]);
6733
6983
  const peopleTab = DK_PEOPLE_TABS.includes(tab);
6734
6984
  const lastPeople = React.useRef("here");
6735
6985
  if (peopleTab)
6736
6986
  lastPeople.current = tab;
6737
6987
  const unreadTotal = (peers || []).reduce((n, p) => n + (Number(p.unread) || 0), 0) + (requests || []).length;
6738
- const phoneNav = [
6739
- { id: "chat", icon: "message", label: T.chat, active: tab === "chat", badge: unreadTotal, go: () => setTab("chat") },
6740
- { id: "people", icon: "users", label: T.people, active: peopleTab, go: () => setTab(lastPeople.current) },
6741
- { id: "network", icon: "network", label: T.network, active: tab === "network", go: () => setTab("network") },
6742
- { id: "apps", icon: "grid", label: T.apps, active: tab === "apps", go: () => setTab("apps") },
6743
- { id: "profile", icon: "userRound", label: T.profile, active: tab === "profile", go: () => setTab("profile") }
6744
- ];
6988
+ const NAV_FOR = {
6989
+ chat: { icon: "message", label: T.chat, active: tab === "chat", badge: unreadTotal, go: () => setTab("chat") },
6990
+ people: { icon: "users", label: T.people, active: peopleTab, go: () => setTab(lastPeople.current) },
6991
+ network: { icon: "network", label: T.network, active: tab === "network", go: () => setTab("network") },
6992
+ apps: { icon: "grid", label: T.apps, active: tab === "apps", go: () => setTab("apps") },
6993
+ profile: { icon: "userRound", label: T.profile, active: tab === "profile", go: () => setTab("profile") }
6994
+ };
6995
+ const phoneNav = tabs.map((x) => x.kind === "builtin" ? { id: x.id, ...NAV_FOR[x.id] } : {
6996
+ id: x.id,
6997
+ icon: typeof x.icon === "string" ? x.icon : void 0,
6998
+ svgPath: x.icon && typeof x.icon === "object" ? x.icon.svgPath : void 0,
6999
+ label: pickText(x.label, t.lang, x.id),
7000
+ active: tab === x.id,
7001
+ // A host badge is called on every render, like the unread count beside
7002
+ // it; a throwing one must not take the tab bar down with it.
7003
+ badge: (() => {
7004
+ try {
7005
+ return x.badge ? x.badge() : 0;
7006
+ } catch (e) {
7007
+ return 0;
7008
+ }
7009
+ })(),
7010
+ go: () => setTab(x.id)
7011
+ });
6745
7012
  const inConversation = phone && tab === "chat" && !!activeId && (peers || []).some((p) => p.id === activeId);
6746
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", flexDirection: phone ? "column" : "row", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement(DkPhoneStyles, null), !phone && /* @__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(Brand, { T, style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8, flexShrink: 0 } }, /* @__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: {
7013
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", flexDirection: phone ? "column" : "row", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement(DkPhoneStyles, null), !phone && /* @__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(Brand, { T, style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8, flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(
7014
+ RailBtn,
7015
+ {
7016
+ key: n.id,
7017
+ icon: typeof n.icon === "string" ? n.icon : void 0,
7018
+ svgPath: n.icon && typeof n.icon === "object" ? n.icon.svgPath : void 0,
7019
+ label: n.label,
7020
+ active: tab === n.id,
7021
+ soon: n.soon,
7022
+ onClick: () => setTab(n.id)
7023
+ }
7024
+ )), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), showProfile && /* @__PURE__ */ React.createElement("button", { onClick: () => setTab("profile"), title: T.profile, style: {
6747
7025
  padding: 4,
6748
7026
  borderRadius: 12,
6749
7027
  cursor: "pointer",
@@ -6764,10 +7042,22 @@ ${peer.address}`
6764
7042
  fontWeight: 600,
6765
7043
  background: tab === n.id ? "var(--accent)" : "transparent",
6766
7044
  color: tab === n.id ? "#fff" : "var(--dim)"
6767
- } }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), 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, onSetAvatar: (p) => dkApi.setProfileFull(p).then((r) => {
6768
- data.refresh();
6769
- return r;
6770
- }) })), phone && !inConversation && /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, display: "flex", borderTop: "1px solid var(--line)", background: "var(--rail)", paddingBottom: "env(safe-area-inset-bottom, 0px)" } }, phoneNav.map((n) => /* @__PURE__ */ React.createElement(TabBarBtn, { key: n.id, icon: n.icon, label: n.label, active: n.active, badge: n.badge, onClick: n.go }))), data.locked && /* @__PURE__ */ React.createElement(
7045
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), 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, me, peers, phone, setTab }), tabs.map((x) => x.kind === "host" && tab === x.id ? /* @__PURE__ */ React.createElement(HostTabPane, { key: x.id, tab: x, props: { T, t, me, peers, phone, setTab }, lang: t.lang }) : null), tab === "profile" && /* @__PURE__ */ React.createElement(
7046
+ ProfileTab,
7047
+ {
7048
+ T,
7049
+ me,
7050
+ onEdit,
7051
+ t,
7052
+ setTweak,
7053
+ onSetAvatar: (p) => dkApi.setProfileFull(p).then((r) => {
7054
+ data.refresh();
7055
+ return r;
7056
+ }),
7057
+ sections: hostProfileSections,
7058
+ sectionProps: { T, t, me, peers, phone, setTab }
7059
+ }
7060
+ )), phone && !inConversation && /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, display: "flex", borderTop: "1px solid var(--line)", background: "var(--rail)", paddingBottom: "env(safe-area-inset-bottom, 0px)" } }, phoneNav.map((n) => /* @__PURE__ */ React.createElement(TabBarBtn, { key: n.id, icon: n.icon, svgPath: n.svgPath, label: n.label, active: n.active, badge: n.badge, onClick: n.go }))), data.locked && /* @__PURE__ */ React.createElement(
6771
7061
  DkLockedOut,
6772
7062
  {
6773
7063
  compact: true,
package/dist/server.d.ts CHANGED
@@ -28,6 +28,10 @@ export interface BeagleServerOptions {
28
28
  * (never written) by GET /api/assistant/friends to count a friend's
29
29
  * history without hauling it over IPC. Undefined = no counts. */
30
30
  messagesPath?: string;
31
+ /** <configDir>/service-peers-local.json — peers THIS machine has adopted as
32
+ * service peers on top of the list shipped in the app. Written only by the
33
+ * Assistant's `/nodes refresh`, and only after the user ticks the rows. */
34
+ servicePeersPath?: string;
31
35
  /** The swappable backend, so the UI can show which one is live and drive the
32
36
  * LAN handoff. Optional: a caller that pins one backend just omits it and
33
37
  * the network panel falls back to "no virtual LAN here". */
package/dist/server.js CHANGED
@@ -121,6 +121,21 @@ function friendStoreStamps(keyFile) {
121
121
  return friendStampsCache?.map ?? empty;
122
122
  }
123
123
  }
124
+ /** The machine-local service-peer overlay. Absent file = nothing adopted yet,
125
+ * which is the normal state, not an error. */
126
+ function readLocalServicePeers(path) {
127
+ const empty = { version: 1, source: "local", peers: [] };
128
+ if (!path || !existsSync(path))
129
+ return empty;
130
+ try {
131
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
132
+ const peers = Array.isArray(raw?.peers) ? raw.peers.filter((p) => p && typeof p.userid === "string") : [];
133
+ return { version: 1, source: "local", updated: raw?.updated, peers };
134
+ }
135
+ catch {
136
+ return empty;
137
+ }
138
+ }
124
139
  /** The record beagle writes for its own name: what the profile page shows
125
140
  * (displayName, description) on top of whatever the record already held —
126
141
  * wallet binds, socials, avatar, referee — so a profile edit never strips a
@@ -2238,6 +2253,95 @@ export function startBeagleServer(opts) {
2238
2253
  sendJson(res, 200, { me, peers, requests, exits, activeExit });
2239
2254
  return;
2240
2255
  }
2256
+ // The locally adopted service peers: the app ships a list, and this is
2257
+ // what this machine added to it. Kept server-side rather than in a tab's
2258
+ // localStorage so every browser on this machine agrees about what is a
2259
+ // node — the classification decides what `/friends remove` can touch.
2260
+ if (req.method === "GET" && url === "/api/assistant/service-peers") {
2261
+ sendJson(res, 200, readLocalServicePeers(opts.servicePeersPath));
2262
+ return;
2263
+ }
2264
+ if (req.method === "POST" && url === "/api/assistant/service-peers") {
2265
+ const body = (await readBody(req));
2266
+ if (!opts.servicePeersPath)
2267
+ return sendJson(res, 400, { ok: false, error: "no config dir" });
2268
+ const current = readLocalServicePeers(opts.servicePeersPath);
2269
+ const byId = new Map(current.peers.map((p) => [p.userid, p]));
2270
+ for (const raw of Array.isArray(body.add) ? body.add : []) {
2271
+ const p = raw;
2272
+ const userid = typeof p?.userid === "string" ? p.userid : "";
2273
+ if (!userid)
2274
+ continue;
2275
+ byId.set(userid, {
2276
+ userid,
2277
+ name: String(p.name ?? userid).slice(0, 120),
2278
+ kind: p.kind === "agent" ? "agent" : "node",
2279
+ role: String(p.role ?? "").slice(0, 120),
2280
+ });
2281
+ }
2282
+ for (const raw of Array.isArray(body.remove) ? body.remove : []) {
2283
+ if (typeof raw === "string")
2284
+ byId.delete(raw);
2285
+ }
2286
+ const next = { version: 1, source: "local", updated: new Date().toISOString().slice(0, 10), peers: [...byId.values()] };
2287
+ try {
2288
+ writeFileSync(opts.servicePeersPath, JSON.stringify(next, null, 2) + "\n");
2289
+ }
2290
+ catch (e) {
2291
+ return sendJson(res, 500, { ok: false, error: e.message });
2292
+ }
2293
+ sendJson(res, 200, { ok: true, ...next });
2294
+ return;
2295
+ }
2296
+ // `/nodes refresh`: which friends does this device have EVIDENCE for?
2297
+ // Evidence only — the shipped exit table and, on a dora, its roster.
2298
+ // Nothing here reads a name or guesses from silence, and nothing is
2299
+ // adopted without the user ticking it.
2300
+ if (req.method === "GET" && url === "/api/assistant/service-candidates") {
2301
+ const flist = await opts.call({ op: "friends-list" });
2302
+ const fl = flist.ok ? (flist.data?.friends ?? []) : [];
2303
+ const evidence = new Map();
2304
+ for (const e of DEFAULT_EXITS) {
2305
+ // Role, not region: the exit table's regions carry place names this
2306
+ // app does not put in front of users (see CLAUDE.md), and "which
2307
+ // exit" is not what this answer is for.
2308
+ if (e.userid)
2309
+ evidence.set(e.userid, { kind: "node", role: "exit", why: "in the shipped exit table" });
2310
+ }
2311
+ let doraCount = 0;
2312
+ if (opts.doraRosterPath && existsSync(opts.doraRosterPath)) {
2313
+ try {
2314
+ const r = yaml.load(readFileSync(opts.doraRosterPath, "utf-8"));
2315
+ for (const rec of r?.records ?? []) {
2316
+ const uid = rec.userid || rec.pubkey;
2317
+ if (!uid)
2318
+ continue;
2319
+ doraCount++;
2320
+ evidence.set(uid, { kind: "node", role: rec.ip ? `dora member (${rec.ip})` : "dora member", why: "allocated in this machine's dora roster" });
2321
+ }
2322
+ }
2323
+ catch { /* a broken roster is not an error here */ }
2324
+ }
2325
+ const candidates = fl
2326
+ .map((f) => ({ f, ev: evidence.get(f.userid ?? "") }))
2327
+ .filter((x) => x.ev)
2328
+ .map(({ f, ev }) => ({
2329
+ userid: f.userid,
2330
+ name: f.alias || (typeof f.name === "string" ? f.name : "") || f.userid,
2331
+ kind: ev.kind,
2332
+ role: ev.role,
2333
+ why: ev.why,
2334
+ }));
2335
+ sendJson(res, 200, {
2336
+ ok: true,
2337
+ candidates,
2338
+ sources: {
2339
+ exits: DEFAULT_EXITS.length,
2340
+ dora: opts.doraRosterPath ? doraCount : null,
2341
+ },
2342
+ });
2343
+ return;
2344
+ }
2241
2345
  // Everything the Assistant's friend filters need, in one answer: the
2242
2346
  // friend list plus how much was said to each friend and when. The
2243
2347
  // counts come from the store file in our own config dir — see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.111",
3
+ "version": "0.1.113",
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",
@@ -35,7 +35,7 @@
35
35
  "yargs": "^17.7.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@decentnetwork/beagle-ui": "^0.2.25",
38
+ "@decentnetwork/beagle-ui": "^0.2.27",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",