@decentnetwork/beagle 0.1.126 → 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 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,
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.126";
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.126";
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.126";
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.126";
626
629
  if (!c)
627
630
  return;
628
631
  const arr = [...c.byId.values()].sort((a, b) => a.ts - b.ts);
629
- const msgs = arr.map(dkThreadMsg);
630
- const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
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 ", r.via, " \xB7 ", r.time))), r.descr && /* @__PURE__ */ React.createElement("div", { style: {
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({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, onAssistantAdopt, answered, selMode, selected, onToggleSel, busy, isGroup }) {
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
+ }
@@ -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;
@@ -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
- via: p.hello || "lan",
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,7 +26,8 @@ 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";
@@ -947,6 +948,7 @@ export function startBeagleServer(opts) {
947
948
  const host = opts.listenHost ?? "127.0.0.1";
948
949
  const port = opts.listenPort ?? 8765;
949
950
  const log = opts.log ?? ((s) => console.log(s));
951
+ const greetings = opts.greetingsPath ? new GreetingStore(opts.greetingsPath) : null;
950
952
  const sendJson = (res, code, body) => {
951
953
  res.writeHead(code, { "content-type": "application/json" });
952
954
  res.end(JSON.stringify(body));
@@ -2436,7 +2438,18 @@ export function startBeagleServer(opts) {
2436
2438
  const bef = Number(q.get("before"));
2437
2439
  const before = Number.isFinite(bef) && bef > 0 ? bef : undefined;
2438
2440
  const r = await opts.call({ op: "chat-history", userid: peer, peer, before, limit });
2439
- sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
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);
2440
2453
  return;
2441
2454
  }
2442
2455
  // Brief 30: submit a rendered form. The values are the user's own input;
@@ -2556,6 +2569,8 @@ export function startBeagleServer(opts) {
2556
2569
  if (req.method === "POST" && url === "/api/friend-remove") {
2557
2570
  const { userid } = await readBody(req);
2558
2571
  const r = await opts.call({ op: "friend-remove", userid });
2572
+ if (r.ok)
2573
+ greetings?.remove(userid);
2559
2574
  sendJson(res, r.ok ? 200 : 400, r);
2560
2575
  return;
2561
2576
  }
@@ -2637,7 +2652,21 @@ export function startBeagleServer(opts) {
2637
2652
  }
2638
2653
  if (req.method === "POST" && url === "/api/accept") {
2639
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
+ }
2640
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
+ }
2641
2670
  sendJson(res, r.ok ? 200 : 400, r);
2642
2671
  return;
2643
2672
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.126",
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.35",
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",