@decentnetwork/beagle 0.1.125 → 0.1.127
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 +1 -0
- package/dist/desktop/app.js +118 -7
- package/dist/friend-greetings.d.ts +45 -0
- package/dist/friend-greetings.js +119 -0
- package/dist/global-prefix.d.ts +13 -0
- package/dist/global-prefix.js +25 -0
- package/dist/pending-requests.d.ts +3 -0
- package/dist/pending-requests.js +13 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.js +67 -12
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -173,6 +173,7 @@ async function main() {
|
|
|
173
173
|
downloadsDir: resolve(args.configDir, "downloads"),
|
|
174
174
|
messagesPath: resolve(args.configDir, "messages.json"),
|
|
175
175
|
servicePeersPath: resolve(args.configDir, "service-peers-local.json"),
|
|
176
|
+
greetingsPath: resolve(args.configDir, "friend-greetings.json"),
|
|
176
177
|
callIce: {
|
|
177
178
|
keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
|
|
178
179
|
bootstrapNodes,
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.127";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -272,7 +272,7 @@ window.__DK_UI_VERSION="0.1.125";
|
|
|
272
272
|
};
|
|
273
273
|
(thread || []).forEach((m, i) => {
|
|
274
274
|
const hit = m && !m.day ? isProto({ text: m.text, dir: m && m.from === "me" ? "out" : "in", ts: m && m.ts }) : false;
|
|
275
|
-
if (!hit || m && m.file) {
|
|
275
|
+
if (!hit || m && (m.file || m.kind === "friend-request")) {
|
|
276
276
|
flush();
|
|
277
277
|
out.push(m);
|
|
278
278
|
return;
|
|
@@ -529,7 +529,10 @@ window.__DK_UI_VERSION="0.1.125";
|
|
|
529
529
|
// Brief 30: a validated component form, already whitelisted host-side.
|
|
530
530
|
// Passed through untouched — the renderer treats every string as text,
|
|
531
531
|
// never markup.
|
|
532
|
-
components: m.components
|
|
532
|
+
components: m.components,
|
|
533
|
+
// 'friend-request': the accepted request's greeting, merged in by the
|
|
534
|
+
// host (beagle's friend-greetings.ts); chat.jsx draws its own bubble.
|
|
535
|
+
kind: m.kind
|
|
533
536
|
};
|
|
534
537
|
}
|
|
535
538
|
var DK_TAIL_MIN = 30;
|
|
@@ -626,8 +629,16 @@ window.__DK_UI_VERSION="0.1.125";
|
|
|
626
629
|
if (!c)
|
|
627
630
|
return;
|
|
628
631
|
const arr = [...c.byId.values()].sort((a, b) => a.ts - b.ts);
|
|
629
|
-
const
|
|
630
|
-
|
|
632
|
+
const withDay = [];
|
|
633
|
+
let lastDay = null;
|
|
634
|
+
for (const m of arr) {
|
|
635
|
+
const day = dkDayLabel(m.ts);
|
|
636
|
+
if (day !== lastDay) {
|
|
637
|
+
withDay.push({ day });
|
|
638
|
+
lastDay = day;
|
|
639
|
+
}
|
|
640
|
+
withDay.push(dkThreadMsg(m));
|
|
641
|
+
}
|
|
631
642
|
setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
|
|
632
643
|
setEarlier((e) => e[peerId] === c.more ? e : Object.assign({}, e, { [peerId]: c.more }));
|
|
633
644
|
}, []);
|
|
@@ -1728,6 +1739,25 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1728
1739
|
return { kind: "punk", id };
|
|
1729
1740
|
return { kind: "ens" };
|
|
1730
1741
|
}
|
|
1742
|
+
var REQUEST_TRANSPORTS = ["lan", "carrier", "direct", "relay", "express", "webrtc", "online", "offline", "tcp", "udp", "p2p"];
|
|
1743
|
+
function unquote(s) {
|
|
1744
|
+
const m = /^"([\s\S]*)"$/.exec(s);
|
|
1745
|
+
return m ? m[1] : s;
|
|
1746
|
+
}
|
|
1747
|
+
function requestGreeting(r) {
|
|
1748
|
+
const hello = r && typeof r.hello === "string" ? r.hello.trim() : "";
|
|
1749
|
+
const via = r && typeof r.via === "string" ? r.via.trim() : "";
|
|
1750
|
+
const isTransport = !!via && REQUEST_TRANSPORTS.includes(via.toLowerCase());
|
|
1751
|
+
const isName = !!via && !!r && typeof r.name === "string" && r.name.trim() === via;
|
|
1752
|
+
return {
|
|
1753
|
+
greeting: hello || (via && !isTransport && !isName ? unquote(via).trim() : ""),
|
|
1754
|
+
transport: isTransport ? via : ""
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
var GREETING_KIND = "friend-request";
|
|
1758
|
+
function isGreetingItem(m) {
|
|
1759
|
+
return !!m && m.kind === GREETING_KIND;
|
|
1760
|
+
}
|
|
1731
1761
|
var DK_CMD_HIST_KEY = "beagle.assistant.history";
|
|
1732
1762
|
var DK_CMD_HIST_MAX = 50;
|
|
1733
1763
|
function RequestsBlock({ T, requests, onAct }) {
|
|
@@ -1779,7 +1809,7 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1779
1809
|
overflow: "hidden",
|
|
1780
1810
|
textOverflow: "ellipsis",
|
|
1781
1811
|
whiteSpace: "nowrap"
|
|
1782
|
-
} }, r.name) : /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", fontStyle: "italic" } }, T.noName || "no name given"), /* @__PURE__ */ React.createElement(Mono, { size: 11, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via "
|
|
1812
|
+
} }, r.name) : /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", fontStyle: "italic" } }, T.noName || "no name given"), /* @__PURE__ */ React.createElement(Mono, { size: 11, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, requestGreeting(r).transport ? "via " + requestGreeting(r).transport + " \xB7 " : "", r.time))), /* @__PURE__ */ React.createElement(DkRequestGreeting, { T, text: requestGreeting(r).greeting }), r.descr && /* @__PURE__ */ React.createElement("div", { style: {
|
|
1783
1813
|
fontFamily: "var(--ui)",
|
|
1784
1814
|
fontSize: 12,
|
|
1785
1815
|
color: "var(--dim, var(--faint))",
|
|
@@ -1788,6 +1818,76 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1788
1818
|
paddingLeft: 8
|
|
1789
1819
|
} }, r.descr), failed[r.id] && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--danger)" } }, failed[r.id]), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => act(r, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => act(r, "reject"), style: { flex: 1 } }, T.reject))))));
|
|
1790
1820
|
}
|
|
1821
|
+
function DkRequestGreeting({ T, text }) {
|
|
1822
|
+
const [open, setOpen] = React.useState(false);
|
|
1823
|
+
const [clipped, setClipped] = React.useState(false);
|
|
1824
|
+
const ref = React.useRef(null);
|
|
1825
|
+
React.useLayoutEffect(() => {
|
|
1826
|
+
const el = ref.current;
|
|
1827
|
+
if (el && !open)
|
|
1828
|
+
setClipped(el.scrollHeight > el.clientHeight + 1);
|
|
1829
|
+
}, [text, open]);
|
|
1830
|
+
if (!text)
|
|
1831
|
+
return null;
|
|
1832
|
+
const toggle = () => {
|
|
1833
|
+
try {
|
|
1834
|
+
const sel = window.getSelection && window.getSelection();
|
|
1835
|
+
if (sel && String(sel).length)
|
|
1836
|
+
return;
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
}
|
|
1839
|
+
if (clipped || open)
|
|
1840
|
+
setOpen((o) => !o);
|
|
1841
|
+
};
|
|
1842
|
+
return /* @__PURE__ */ React.createElement(
|
|
1843
|
+
"div",
|
|
1844
|
+
{
|
|
1845
|
+
onClick: toggle,
|
|
1846
|
+
title: !open && clipped ? T.showAll || "show all" : void 0,
|
|
1847
|
+
style: { cursor: clipped || open ? "pointer" : "text" }
|
|
1848
|
+
},
|
|
1849
|
+
/* @__PURE__ */ React.createElement("div", { style: { background: "var(--bub-them)", border: "1px solid var(--line)", borderRadius: 8, padding: "6px 9px" } }, /* @__PURE__ */ React.createElement("div", { ref, style: {
|
|
1850
|
+
fontFamily: "var(--ui)",
|
|
1851
|
+
fontSize: 12.5,
|
|
1852
|
+
color: "var(--text)",
|
|
1853
|
+
lineHeight: 1.45,
|
|
1854
|
+
whiteSpace: "pre-wrap",
|
|
1855
|
+
overflowWrap: "anywhere",
|
|
1856
|
+
userSelect: "text",
|
|
1857
|
+
WebkitUserSelect: "text",
|
|
1858
|
+
...open ? { maxHeight: 240, overflowY: "auto" } : { display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }
|
|
1859
|
+
} }, text)),
|
|
1860
|
+
(clipped || open) && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--accent)", marginTop: 3 } }, open ? T.showLess || "show less" : T.showAll || "show all")
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
function DkGreetingMsg({ m, peer, T }) {
|
|
1864
|
+
return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0 10px" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: {
|
|
1865
|
+
maxWidth: "75%",
|
|
1866
|
+
padding: "8px 12px",
|
|
1867
|
+
borderRadius: 12,
|
|
1868
|
+
borderBottomLeftRadius: 4,
|
|
1869
|
+
background: "var(--panel-2)",
|
|
1870
|
+
border: "1px dashed var(--accent)",
|
|
1871
|
+
color: "var(--text)"
|
|
1872
|
+
} }, /* @__PURE__ */ React.createElement("div", { style: {
|
|
1873
|
+
display: "flex",
|
|
1874
|
+
alignItems: "center",
|
|
1875
|
+
gap: 6,
|
|
1876
|
+
marginBottom: 4,
|
|
1877
|
+
fontFamily: "var(--mono)",
|
|
1878
|
+
fontSize: 10.5,
|
|
1879
|
+
color: "var(--accent)",
|
|
1880
|
+
fontWeight: 600
|
|
1881
|
+
} }, /* @__PURE__ */ React.createElement(Icon, { name: "userPlus", size: 12, stroke: 2.2, color: "var(--accent)" }), /* @__PURE__ */ React.createElement("span", null, T.friendRequestItem || "Friend request"), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)", fontWeight: 400 } }, "\xB7 ", m.time)), /* @__PURE__ */ React.createElement("div", { style: {
|
|
1882
|
+
fontFamily: "var(--ui)",
|
|
1883
|
+
fontSize: 13.5,
|
|
1884
|
+
lineHeight: 1.5,
|
|
1885
|
+
whiteSpace: "pre-wrap",
|
|
1886
|
+
overflowWrap: "anywhere",
|
|
1887
|
+
userSelect: "text",
|
|
1888
|
+
WebkitUserSelect: "text"
|
|
1889
|
+
} }, m.text)));
|
|
1890
|
+
}
|
|
1791
1891
|
function DkRequestAvatar({ r, size, radius }) {
|
|
1792
1892
|
const [urlBroken, setUrlBroken] = React.useState(false);
|
|
1793
1893
|
const url = requestAvatarUrl(r);
|
|
@@ -2562,7 +2662,12 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
2562
2662
|
return null;
|
|
2563
2663
|
}), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
|
|
2564
2664
|
}
|
|
2565
|
-
function Msg(
|
|
2665
|
+
function Msg(props) {
|
|
2666
|
+
if (isGreetingItem(props.m))
|
|
2667
|
+
return /* @__PURE__ */ React.createElement(DkGreetingMsg, { m: props.m, peer: props.peer, T: props.T });
|
|
2668
|
+
return /* @__PURE__ */ React.createElement(DkMsgBody, { ...props });
|
|
2669
|
+
}
|
|
2670
|
+
function DkMsgBody({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, onAssistantAdopt, answered, selMode, selected, onToggleSel, busy, isGroup }) {
|
|
2566
2671
|
const mine = m.from === "me";
|
|
2567
2672
|
const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
|
|
2568
2673
|
const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
|
|
@@ -6468,6 +6573,9 @@ ${peer.address}`
|
|
|
6468
6573
|
dirSearch: "search name / userid\u2026",
|
|
6469
6574
|
dirLoading: "loading directory\u2026",
|
|
6470
6575
|
noName: "no name given",
|
|
6576
|
+
friendRequestItem: "Friend request",
|
|
6577
|
+
showAll: "show all",
|
|
6578
|
+
showLess: "show less",
|
|
6471
6579
|
dirEmpty: "nobody matches",
|
|
6472
6580
|
dirError: "Could not load the directory:",
|
|
6473
6581
|
bridgeEmpty: "Nobody else is listed on this bridge yet. You are the first.",
|
|
@@ -6667,6 +6775,9 @@ ${peer.address}`
|
|
|
6667
6775
|
dirSearch: "\u641C\u7D22\u540D\u5B57 / userid\u2026",
|
|
6668
6776
|
dirLoading: "\u6B63\u5728\u52A0\u8F7D\u540D\u5355\u2026",
|
|
6669
6777
|
noName: "\u5BF9\u65B9\u6CA1\u6709\u586B\u540D\u5B57",
|
|
6778
|
+
friendRequestItem: "\u597D\u53CB\u8BF7\u6C42",
|
|
6779
|
+
showAll: "\u5C55\u5F00\u5168\u6587",
|
|
6780
|
+
showLess: "\u6536\u8D77",
|
|
6670
6781
|
dirEmpty: "\u6CA1\u6709\u5339\u914D\u7684\u4EBA",
|
|
6671
6782
|
dirError: "\u540D\u5355\u52A0\u8F7D\u5931\u8D25\uFF1A",
|
|
6672
6783
|
bridgeEmpty: "\u8FD9\u4E2A bridge \u4E0A\u8FD8\u6CA1\u6709\u522B\u4EBA\uFF0C\u4F60\u662F\u7B2C\u4E00\u4E2A\u3002",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare const GREETING_KIND = "friend-request";
|
|
2
|
+
/** One greeting as stored, per friend. */
|
|
3
|
+
export interface StoredGreeting {
|
|
4
|
+
text: string;
|
|
5
|
+
/** When the request arrived (ms). The item is dated here, not at accept. */
|
|
6
|
+
ts: number;
|
|
7
|
+
}
|
|
8
|
+
/** The shape merged into a /api/chat-history page — a raw store record with
|
|
9
|
+
* `kind` so beagle-ui draws the distinct bubble. */
|
|
10
|
+
export interface GreetingItem {
|
|
11
|
+
id: string;
|
|
12
|
+
dir: "in";
|
|
13
|
+
text: string;
|
|
14
|
+
ts: number;
|
|
15
|
+
kind: typeof GREETING_KIND;
|
|
16
|
+
}
|
|
17
|
+
/** A request's arrival time in ms. The embedded host stores a number, the lan
|
|
18
|
+
* daemon an ISO string. Undefined when neither parses. */
|
|
19
|
+
export declare function arrivalMs(v: unknown): number | undefined;
|
|
20
|
+
export declare class GreetingStore {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(path: string);
|
|
23
|
+
/** Keep the greeting of an accepted request. Skips an empty / whitespace
|
|
24
|
+
* greeting, and a repeat request carrying the same text (one item per
|
|
25
|
+
* request, not one per click). Returns whether an item was added. */
|
|
26
|
+
record(peer: string, hello: unknown, arrivedAt: unknown, now?: number): boolean;
|
|
27
|
+
/** The items for one conversation, oldest first. */
|
|
28
|
+
items(peer: string): GreetingItem[];
|
|
29
|
+
/** Forget a friend's greetings (friend removed together with the chat). */
|
|
30
|
+
remove(peer: string): void;
|
|
31
|
+
}
|
|
32
|
+
/** Merge greeting items into one /api/chat-history page (oldest first).
|
|
33
|
+
*
|
|
34
|
+
* A greeting belongs on a page when it is older than `before` (if given)
|
|
35
|
+
* and, when the page is full (`limit` records — there are older ones the
|
|
36
|
+
* page left out), no older than the page's oldest record; otherwise it is
|
|
37
|
+
* shown when the user loads earlier and reaches it. Already-present ids are
|
|
38
|
+
* not added twice. Returns a new array. */
|
|
39
|
+
export declare function mergeGreetings<M extends {
|
|
40
|
+
ts?: number;
|
|
41
|
+
id?: string;
|
|
42
|
+
}>(page: readonly M[], greetings: readonly GreetingItem[], opts?: {
|
|
43
|
+
before?: number;
|
|
44
|
+
limit?: number;
|
|
45
|
+
}): Array<M | GreetingItem>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The greeting a requester typed into a friend request, kept after accept.
|
|
2
|
+
//
|
|
3
|
+
// Before this, the greeting lived only in the pending list: readable while the
|
|
4
|
+
// request was open (and drawn as "via <greeting>"), gone the moment it was
|
|
5
|
+
// accepted. On accept beagle now writes it into that friend's conversation as
|
|
6
|
+
// an incoming "friend request" item, dated at the request time.
|
|
7
|
+
//
|
|
8
|
+
// Local only — nothing is sent. Stored in beagle's own file, not in
|
|
9
|
+
// messages.json: with the daemon backend that file belongs to the agentnet
|
|
10
|
+
// daemon, which holds it in memory and would overwrite anything we wrote.
|
|
11
|
+
// /api/chat-history merges these items into the page it returns, so the
|
|
12
|
+
// daemon's unread counts and last-message preview never see them (spec:
|
|
13
|
+
// not unread, no notification, not the "last message" over newer ones).
|
|
14
|
+
// See decent-hq docs/tasks/TASK-2026-11-04-friend-request-greeting.md.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
export const GREETING_KIND = "friend-request";
|
|
18
|
+
/** A request's arrival time in ms. The embedded host stores a number, the lan
|
|
19
|
+
* daemon an ISO string. Undefined when neither parses. */
|
|
20
|
+
export function arrivalMs(v) {
|
|
21
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0)
|
|
22
|
+
return v;
|
|
23
|
+
if (typeof v === "string" && v) {
|
|
24
|
+
const n = Date.parse(v);
|
|
25
|
+
if (Number.isFinite(n) && n > 0)
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export class GreetingStore {
|
|
31
|
+
#path;
|
|
32
|
+
#byPeer = {};
|
|
33
|
+
constructor(path) {
|
|
34
|
+
this.#path = path;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
38
|
+
for (const [peer, list] of Object.entries(raw?.byPeer ?? {})) {
|
|
39
|
+
if (!Array.isArray(list))
|
|
40
|
+
continue;
|
|
41
|
+
const ok = list.filter((g) => !!g && typeof g.text === "string" && typeof g.ts === "number");
|
|
42
|
+
if (ok.length)
|
|
43
|
+
this.#byPeer[peer] = ok;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A corrupt file loses the greetings, never the chat: start empty.
|
|
49
|
+
this.#byPeer = {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Keep the greeting of an accepted request. Skips an empty / whitespace
|
|
53
|
+
* greeting, and a repeat request carrying the same text (one item per
|
|
54
|
+
* request, not one per click). Returns whether an item was added. */
|
|
55
|
+
record(peer, hello, arrivedAt, now = Date.now()) {
|
|
56
|
+
if (!peer || typeof hello !== "string")
|
|
57
|
+
return false;
|
|
58
|
+
const text = hello.trim();
|
|
59
|
+
if (!text)
|
|
60
|
+
return false;
|
|
61
|
+
const list = this.#byPeer[peer] ?? [];
|
|
62
|
+
if (list.some((g) => g.text.trim() === text))
|
|
63
|
+
return false;
|
|
64
|
+
list.push({ text, ts: arrivalMs(arrivedAt) ?? now });
|
|
65
|
+
list.sort((a, b) => a.ts - b.ts);
|
|
66
|
+
this.#byPeer[peer] = list;
|
|
67
|
+
this.#save();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** The items for one conversation, oldest first. */
|
|
71
|
+
items(peer) {
|
|
72
|
+
return (this.#byPeer[peer] ?? []).map((g) => ({
|
|
73
|
+
id: `${GREETING_KIND}:${g.ts}`,
|
|
74
|
+
dir: "in",
|
|
75
|
+
text: g.text,
|
|
76
|
+
ts: g.ts,
|
|
77
|
+
kind: GREETING_KIND,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
/** Forget a friend's greetings (friend removed together with the chat). */
|
|
81
|
+
remove(peer) {
|
|
82
|
+
if (!this.#byPeer[peer])
|
|
83
|
+
return;
|
|
84
|
+
delete this.#byPeer[peer];
|
|
85
|
+
this.#save();
|
|
86
|
+
}
|
|
87
|
+
#save() {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(dirname(this.#path), { recursive: true });
|
|
90
|
+
const tmp = `${this.#path}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, byPeer: this.#byPeer }, null, 2), "utf-8");
|
|
92
|
+
renameSync(tmp, this.#path);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Not fatal: the greeting stays for this run and the chat is unaffected.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Merge greeting items into one /api/chat-history page (oldest first).
|
|
100
|
+
*
|
|
101
|
+
* A greeting belongs on a page when it is older than `before` (if given)
|
|
102
|
+
* and, when the page is full (`limit` records — there are older ones the
|
|
103
|
+
* page left out), no older than the page's oldest record; otherwise it is
|
|
104
|
+
* shown when the user loads earlier and reaches it. Already-present ids are
|
|
105
|
+
* not added twice. Returns a new array. */
|
|
106
|
+
export function mergeGreetings(page, greetings, opts = {}) {
|
|
107
|
+
if (!greetings.length)
|
|
108
|
+
return page.slice();
|
|
109
|
+
const full = opts.limit !== undefined && page.length >= opts.limit;
|
|
110
|
+
const oldest = page.length ? Math.min(...page.map((m) => (typeof m.ts === "number" ? m.ts : Infinity))) : Infinity;
|
|
111
|
+
const have = new Set(page.map((m) => m.id).filter(Boolean));
|
|
112
|
+
const add = greetings.filter((g) => !have.has(g.id) &&
|
|
113
|
+
(opts.before === undefined || g.ts < opts.before) &&
|
|
114
|
+
(!full || g.ts >= oldest));
|
|
115
|
+
if (!add.length)
|
|
116
|
+
return page.slice();
|
|
117
|
+
// Stable: a greeting sorts before a message with the same timestamp.
|
|
118
|
+
return [...add, ...page].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
|
|
119
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The npm global prefix a file inside a globally installed package lives
|
|
3
|
+
* under — the value `npm i -g --prefix` needs to replace THAT copy.
|
|
4
|
+
*
|
|
5
|
+
* /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
|
|
6
|
+
* /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
|
|
7
|
+
* C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
|
|
8
|
+
*
|
|
9
|
+
* Empty when the path is not inside a global node_modules (dev checkout, npx).
|
|
10
|
+
* The FIRST match is the global root; a nested dependency's node_modules
|
|
11
|
+
* sits below it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function globalPrefixOf(path: string, s?: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* The npm global prefix a file inside a globally installed package lives
|
|
4
|
+
* under — the value `npm i -g --prefix` needs to replace THAT copy.
|
|
5
|
+
*
|
|
6
|
+
* /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
|
|
7
|
+
* /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
|
|
8
|
+
* C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
|
|
9
|
+
*
|
|
10
|
+
* Empty when the path is not inside a global node_modules (dev checkout, npx).
|
|
11
|
+
* The FIRST match is the global root; a nested dependency's node_modules
|
|
12
|
+
* sits below it.
|
|
13
|
+
*/
|
|
14
|
+
export function globalPrefixOf(path, s = sep) {
|
|
15
|
+
const posix = `${s}lib${s}node_modules${s}`;
|
|
16
|
+
const i = path.indexOf(posix);
|
|
17
|
+
if (i > 0)
|
|
18
|
+
return path.slice(0, i);
|
|
19
|
+
if (s === "\\") {
|
|
20
|
+
const w = path.indexOf(`${s}node_modules${s}`);
|
|
21
|
+
if (w > 0)
|
|
22
|
+
return path.slice(0, w);
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
@@ -26,3 +26,6 @@ export declare function refreshPendingAvatars<E extends {
|
|
|
26
26
|
* the same convention as `peers[]` — and the card then falls back to the
|
|
27
27
|
* beagles.eth lookup and a generated picture. */
|
|
28
28
|
export declare function desktopRequestRow(p: Record<string, unknown>, i: number, fmtTime: (ts?: number) => string): Record<string, unknown>;
|
|
29
|
+
/** The pending entry the UI's Accept refers to: the card's id is the userid,
|
|
30
|
+
* or the address when the request carried no userid. */
|
|
31
|
+
export declare function findPending(pending: ReadonlyArray<Record<string, unknown>>, id: unknown): Record<string, unknown> | undefined;
|
package/dist/pending-requests.js
CHANGED
|
@@ -60,10 +60,22 @@ export function desktopRequestRow(p, i, fmtTime) {
|
|
|
60
60
|
// was nothing to decide on.
|
|
61
61
|
name: p.name || "",
|
|
62
62
|
descr: p.descr || "",
|
|
63
|
-
|
|
63
|
+
// The greeting the requester typed. It used to ride in `via`, so the card
|
|
64
|
+
// drew it as "via <greeting>" — mislabeled, and gone after accept.
|
|
65
|
+
// beagle-ui >= 0.2.36 reads `hello`; for an older UI, `via` stays the
|
|
66
|
+
// transport word it always fell back to.
|
|
67
|
+
hello: typeof p.hello === "string" ? p.hello : "",
|
|
68
|
+
via: p.via || "lan",
|
|
64
69
|
time: fmtTime(p.arrivedAt),
|
|
65
70
|
// The requester's avatar, drawn before accept: URL first, then punk.
|
|
66
71
|
punkId: a.punkId ?? null,
|
|
67
72
|
avatarUrl: a.avatarUrl ?? null,
|
|
68
73
|
};
|
|
69
74
|
}
|
|
75
|
+
/** The pending entry the UI's Accept refers to: the card's id is the userid,
|
|
76
|
+
* or the address when the request carried no userid. */
|
|
77
|
+
export function findPending(pending, id) {
|
|
78
|
+
if (typeof id !== "string" || !id)
|
|
79
|
+
return undefined;
|
|
80
|
+
return pending.find((p) => p.userid === id || p.address === id || p.pubkey === id);
|
|
81
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface BeagleServerOptions {
|
|
|
32
32
|
* service peers on top of the list shipped in the app. Written only by the
|
|
33
33
|
* Assistant's `/nodes refresh`, and only after the user ticks the rows. */
|
|
34
34
|
servicePeersPath?: string;
|
|
35
|
+
/** <configDir>/friend-greetings.json — the greetings of accepted friend
|
|
36
|
+
* requests, shown as the first item of that chat. Beagle's own file (see
|
|
37
|
+
* friend-greetings.ts). Undefined = greetings are not kept. */
|
|
38
|
+
greetingsPath?: string;
|
|
35
39
|
/** The swappable backend, so the UI can show which one is live and drive the
|
|
36
40
|
* LAN handoff. Optional: a caller that pins one backend just omits it and
|
|
37
41
|
* the network panel falls back to "no virtual LAN here". */
|
package/dist/server.js
CHANGED
|
@@ -26,10 +26,12 @@ import { bridgeRoster } from "./bridge-roster.js";
|
|
|
26
26
|
import { isSupervised } from "./relaunch.js";
|
|
27
27
|
import { historyStats } from "./assistant-stats.js";
|
|
28
28
|
import { readFriendStore, pickPunk } from "./friend-store.js";
|
|
29
|
-
import { desktopRequestRow } from "./pending-requests.js";
|
|
29
|
+
import { desktopRequestRow, findPending } from "./pending-requests.js";
|
|
30
|
+
import { GreetingStore, mergeGreetings } from "./friend-greetings.js";
|
|
30
31
|
import { createDiscoverFetch } from "./discover-cache.js";
|
|
31
32
|
import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
|
|
32
33
|
import { settleUpload } from "./upload-settle.js";
|
|
34
|
+
import { globalPrefixOf } from "./global-prefix.js";
|
|
33
35
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
34
36
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
35
37
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
@@ -345,9 +347,15 @@ function verLt(a, b) {
|
|
|
345
347
|
* different node than this app (nvm: launchd starts v24.2.0 while the shell's
|
|
346
348
|
* npm is v24.3.0), and a global install lands in the prefix of whichever npm
|
|
347
349
|
* ran it — the wrong one leaves the daemon on the old code with no error. */
|
|
348
|
-
|
|
350
|
+
/** `targetPrefix` is the global prefix the RUNNING copy lives in. Pinning node
|
|
351
|
+
* is not enough: an nvm npm-cli.js runs under whatever node its caller picks,
|
|
352
|
+
* and npm on PATH (pm2's PATH on mac-dev: v24.3.0) installs into its own
|
|
353
|
+
* prefix — beagle 0.1.124 ran from v24.2.0 while every click of Update put
|
|
354
|
+
* the new version into v24.3.0, reported success, and restarted onto the old
|
|
355
|
+
* code. An explicit --prefix puts the files where the restart will load them. */
|
|
356
|
+
async function npmGlobalInstall(pkg, nodePath, targetPrefix) {
|
|
349
357
|
const { spawn } = await import("node:child_process");
|
|
350
|
-
const args = ["i", "-g", `${pkg}@latest
|
|
358
|
+
const args = ["i", "-g", `${pkg}@latest`, ...(targetPrefix ? ["--prefix", targetPrefix] : [])];
|
|
351
359
|
const cliFor = (node) => join(dirname(node), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
352
360
|
const npmCli = cliFor(process.execPath);
|
|
353
361
|
const pinned = nodePath && existsSync(nodePath) && existsSync(cliFor(nodePath)) ? cliFor(nodePath) : "";
|
|
@@ -386,10 +394,10 @@ async function npmGlobalInstall(pkg, nodePath) {
|
|
|
386
394
|
const npmCmd = a.cmd === "npm" ? resolveNpmOnPath(npmCli) : [a.cmd, a.args[0]];
|
|
387
395
|
if (npmCmd.length) {
|
|
388
396
|
const [exe, ...pre0] = npmCmd;
|
|
389
|
-
const pre = await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
|
|
390
|
-
const prefix = pre
|
|
397
|
+
const pre = targetPrefix ? undefined : await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
|
|
398
|
+
const prefix = targetPrefix || (pre?.code === 0 ? pre.out.trim().split("\n").pop()?.trim() : "");
|
|
391
399
|
if (prefix) {
|
|
392
|
-
const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, "--prefix", prefix]);
|
|
400
|
+
const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, ...(targetPrefix ? [] : ["--prefix", prefix])]);
|
|
393
401
|
if (s.code === 0)
|
|
394
402
|
return { ok: true, log: s.out.slice(-2000) };
|
|
395
403
|
return { ok: false, error: `sudo -n npm exited ${s.code}`, log: s.out.slice(-2000) };
|
|
@@ -555,13 +563,31 @@ async function npmQuery(args) {
|
|
|
555
563
|
/** The node binary the running daemon was started with, from its pid's
|
|
556
564
|
* command line (`/…/bin/node /…/bin/agentnet up …`). Empty when unknown. */
|
|
557
565
|
async function daemonNodePath(dataDir) {
|
|
566
|
+
const first = (await daemonCommand(dataDir))[0] ?? "";
|
|
567
|
+
return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
|
|
568
|
+
}
|
|
569
|
+
/** The global prefix the running daemon's lan was loaded from: its script
|
|
570
|
+
* (`/…/bin/agentnet`, a symlink into `<prefix>/lib/node_modules/…`) resolved.
|
|
571
|
+
* Empty when unknown — the install then falls back to npm's own prefix. */
|
|
572
|
+
async function daemonLanPrefix(dataDir) {
|
|
573
|
+
const script = (await daemonCommand(dataDir))[1] ?? "";
|
|
574
|
+
if (!script || !existsSync(script))
|
|
575
|
+
return "";
|
|
576
|
+
try {
|
|
577
|
+
return globalPrefixOf(realpathSync(script));
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return "";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** The daemon's command line split on whitespace, from its pidfile's pid. */
|
|
584
|
+
async function daemonCommand(dataDir) {
|
|
558
585
|
const pid = dataDir ? readDaemonPid(dataDir) : "";
|
|
559
586
|
if (!/^\d+$/.test(pid) || process.platform === "win32")
|
|
560
|
-
return
|
|
587
|
+
return [];
|
|
561
588
|
const { execFile } = await import("node:child_process");
|
|
562
589
|
const cmd = await new Promise((res) => execFile("ps", ["-o", "command=", "-p", pid], { timeout: 5000 }, (err, out) => res(err ? "" : String(out).trim())));
|
|
563
|
-
|
|
564
|
-
return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
|
|
590
|
+
return cmd ? cmd.split(/\s+/) : [];
|
|
565
591
|
}
|
|
566
592
|
async function agentnetRestart(dataDir) {
|
|
567
593
|
const pidBefore = dataDir ? readDaemonPid(dataDir) : "";
|
|
@@ -922,6 +948,7 @@ export function startBeagleServer(opts) {
|
|
|
922
948
|
const host = opts.listenHost ?? "127.0.0.1";
|
|
923
949
|
const port = opts.listenPort ?? 8765;
|
|
924
950
|
const log = opts.log ?? ((s) => console.log(s));
|
|
951
|
+
const greetings = opts.greetingsPath ? new GreetingStore(opts.greetingsPath) : null;
|
|
925
952
|
const sendJson = (res, code, body) => {
|
|
926
953
|
res.writeHead(code, { "content-type": "application/json" });
|
|
927
954
|
res.end(JSON.stringify(body));
|
|
@@ -1895,7 +1922,7 @@ export function startBeagleServer(opts) {
|
|
|
1895
1922
|
// lan (the agentnet daemon) updates independently of beagle's own
|
|
1896
1923
|
// install mode: global reinstall, then ask the daemon to re-exec.
|
|
1897
1924
|
if (body.pkg === "lan") {
|
|
1898
|
-
const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir));
|
|
1925
|
+
const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir), await daemonLanPrefix(opts.lanHost?.dataDir));
|
|
1899
1926
|
if (!inst.ok) {
|
|
1900
1927
|
sendJson(res, 502, inst);
|
|
1901
1928
|
return;
|
|
@@ -1919,7 +1946,8 @@ export function startBeagleServer(opts) {
|
|
|
1919
1946
|
sendJson(res, 200, { ok: true, npx: true });
|
|
1920
1947
|
return;
|
|
1921
1948
|
}
|
|
1922
|
-
|
|
1949
|
+
// Into the prefix THIS copy runs from, with this node's npm — see npmGlobalInstall.
|
|
1950
|
+
const r = await npmGlobalInstall(BEAGLE_PKG, process.execPath, globalPrefixOf(BEAGLE_ROOT) || undefined);
|
|
1923
1951
|
updateCache = null; // next check reflects the new install
|
|
1924
1952
|
sendJson(res, r.ok ? 200 : 502, r);
|
|
1925
1953
|
return;
|
|
@@ -2410,7 +2438,18 @@ export function startBeagleServer(opts) {
|
|
|
2410
2438
|
const bef = Number(q.get("before"));
|
|
2411
2439
|
const before = Number.isFinite(bef) && bef > 0 ? bef : undefined;
|
|
2412
2440
|
const r = await opts.call({ op: "chat-history", userid: peer, peer, before, limit });
|
|
2413
|
-
|
|
2441
|
+
const data = (r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
|
|
2442
|
+
// The greeting of the accepted friend request, as the chat's first
|
|
2443
|
+
// item (friend-greetings.ts). Merged here, never written to the
|
|
2444
|
+
// daemon's store, so it is not unread and not the roster preview.
|
|
2445
|
+
if (greetings && peer) {
|
|
2446
|
+
const items = greetings.items(peer);
|
|
2447
|
+
if (items.length) {
|
|
2448
|
+
data.chats = data.chats ?? {};
|
|
2449
|
+
data.chats[peer] = mergeGreetings(data.chats[peer] ?? [], items, { before, limit });
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
sendJson(res, 200, data);
|
|
2414
2453
|
return;
|
|
2415
2454
|
}
|
|
2416
2455
|
// Brief 30: submit a rendered form. The values are the user's own input;
|
|
@@ -2530,6 +2569,8 @@ export function startBeagleServer(opts) {
|
|
|
2530
2569
|
if (req.method === "POST" && url === "/api/friend-remove") {
|
|
2531
2570
|
const { userid } = await readBody(req);
|
|
2532
2571
|
const r = await opts.call({ op: "friend-remove", userid });
|
|
2572
|
+
if (r.ok)
|
|
2573
|
+
greetings?.remove(userid);
|
|
2533
2574
|
sendJson(res, r.ok ? 200 : 400, r);
|
|
2534
2575
|
return;
|
|
2535
2576
|
}
|
|
@@ -2611,7 +2652,21 @@ export function startBeagleServer(opts) {
|
|
|
2611
2652
|
}
|
|
2612
2653
|
if (req.method === "POST" && url === "/api/accept") {
|
|
2613
2654
|
const { userid } = await readBody(req);
|
|
2655
|
+
// Read the request BEFORE accepting: accept drops it from pending, and
|
|
2656
|
+
// with it the greeting the requester typed.
|
|
2657
|
+
let entry;
|
|
2658
|
+
if (greetings) {
|
|
2659
|
+
try {
|
|
2660
|
+
const pend = await opts.call({ op: "friends-pending" });
|
|
2661
|
+
if (pend.ok)
|
|
2662
|
+
entry = findPending(pend.data?.pending ?? [], userid);
|
|
2663
|
+
}
|
|
2664
|
+
catch { /* no greeting then; the accept still goes through */ }
|
|
2665
|
+
}
|
|
2614
2666
|
const r = await opts.call({ op: "friends-accept", userid });
|
|
2667
|
+
if (r.ok && greetings && entry) {
|
|
2668
|
+
greetings.record(entry.userid || userid, entry.hello, entry.arrivedAt);
|
|
2669
|
+
}
|
|
2615
2670
|
sendJson(res, r.ok ? 200 : 400, r);
|
|
2616
2671
|
return;
|
|
2617
2672
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.127",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"start": "node dist/cli.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@decentnetwork/beagle-ui": "^0.2.
|
|
29
|
+
"@decentnetwork/beagle-ui": "^0.2.36",
|
|
30
30
|
"@decentnetwork/chat-components": "^0.1.3",
|
|
31
31
|
"@decentnetwork/lan": "^0.1.313",
|
|
32
32
|
"@decentnetwork/peer": "^0.1.170",
|