@decentnetwork/beagle 0.1.105 → 0.1.108

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-friend conversation statistics for the Assistant's friend filters.
3
+ *
4
+ * Computed HERE, in the server process, and never in the browser: the answer
5
+ * for a whole friend list is a few hundred bytes, while the history it is
6
+ * derived from is megabytes (one group thread on this Mac is 9 MB). Paging
7
+ * that into a tab — or over IPC — is what stalled the daemon in 0.1.90.
8
+ *
9
+ * The source is <configDir>/messages.json, the file BOTH backends write (the
10
+ * daemon's src/daemon/message-store.ts and our own src/message-store.ts use
11
+ * the same path and the same shape). Reading it directly is the same move
12
+ * friendStoreStamps() makes for `<keyFile>.friends.json`, and for the same
13
+ * reason: it is a file in our own config dir, and the alternative is an op
14
+ * that hauls every message across a socket to count them.
15
+ *
16
+ * Note for the ports (docs/assistant/SPEC.md): there are no call records in
17
+ * the store, so `lastCallTs` is always undefined and the `never-called`
18
+ * filter is not offered.
19
+ */
20
+ export interface FriendStats {
21
+ /** Earliest message with this friend held on this device. */
22
+ firstMsgTs?: number;
23
+ lastInboundTs?: number;
24
+ lastOutboundTs?: number;
25
+ inboundCount: number;
26
+ outboundCount: number;
27
+ /** Always undefined: the store holds no call records. Kept so the shape
28
+ * matches the spec and a later version can fill it in. */
29
+ lastCallTs?: number;
30
+ }
31
+ export interface HistoryStats {
32
+ byPeer: Map<string, FriendStats>;
33
+ /** Earliest message timestamp held on this device, across every thread.
34
+ * Every Assistant answer is qualified with it: a friend you only talk to
35
+ * on your phone looks silent here, and the user has to see that. */
36
+ historySince?: number;
37
+ }
38
+ /** Read and aggregate <configDir>/messages.json. Cached by mtime+size, so the
39
+ * 9 MB parse happens once per change however many commands are typed. */
40
+ export declare function historyStats(messagesPath: string | undefined): HistoryStats;
41
+ /** Split out from the file read so tests can feed it a plain object. */
42
+ export declare function aggregate(raw: unknown): HistoryStats;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Per-friend conversation statistics for the Assistant's friend filters.
3
+ *
4
+ * Computed HERE, in the server process, and never in the browser: the answer
5
+ * for a whole friend list is a few hundred bytes, while the history it is
6
+ * derived from is megabytes (one group thread on this Mac is 9 MB). Paging
7
+ * that into a tab — or over IPC — is what stalled the daemon in 0.1.90.
8
+ *
9
+ * The source is <configDir>/messages.json, the file BOTH backends write (the
10
+ * daemon's src/daemon/message-store.ts and our own src/message-store.ts use
11
+ * the same path and the same shape). Reading it directly is the same move
12
+ * friendStoreStamps() makes for `<keyFile>.friends.json`, and for the same
13
+ * reason: it is a file in our own config dir, and the alternative is an op
14
+ * that hauls every message across a socket to count them.
15
+ *
16
+ * Note for the ports (docs/assistant/SPEC.md): there are no call records in
17
+ * the store, so `lastCallTs` is always undefined and the `never-called`
18
+ * filter is not offered.
19
+ */
20
+ import { readFileSync, statSync } from "fs";
21
+ const EMPTY = { byPeer: new Map(), historySince: undefined };
22
+ let cache = null;
23
+ /** Read and aggregate <configDir>/messages.json. Cached by mtime+size, so the
24
+ * 9 MB parse happens once per change however many commands are typed. */
25
+ export function historyStats(messagesPath) {
26
+ if (!messagesPath)
27
+ return EMPTY;
28
+ try {
29
+ const st = statSync(messagesPath);
30
+ if (cache && cache.path === messagesPath && cache.mtimeMs === st.mtimeMs && cache.size === st.size) {
31
+ return cache.stats;
32
+ }
33
+ const raw = JSON.parse(readFileSync(messagesPath, "utf-8"));
34
+ const stats = aggregate(raw);
35
+ cache = { path: messagesPath, mtimeMs: st.mtimeMs, size: st.size, stats };
36
+ return stats;
37
+ }
38
+ catch {
39
+ // Absent (nothing said yet), unreadable, or half-written: whatever we last
40
+ // read, else nothing. The friend list still answers, with zero counts —
41
+ // and the "since" line tells the user the history is not known.
42
+ return cache?.stats ?? EMPTY;
43
+ }
44
+ }
45
+ /** Split out from the file read so tests can feed it a plain object. */
46
+ export function aggregate(raw) {
47
+ const byPeer = new Map();
48
+ let historySince;
49
+ const threads = (raw && typeof raw === "object" ? raw : {});
50
+ for (const [peer, value] of Object.entries(threads)) {
51
+ if (!Array.isArray(value))
52
+ continue;
53
+ const s = { inboundCount: 0, outboundCount: 0 };
54
+ for (const m of value) {
55
+ const ts = typeof m?.ts === "number" ? m.ts : undefined;
56
+ if (m?.dir === "in") {
57
+ s.inboundCount++;
58
+ if (ts !== undefined && (s.lastInboundTs === undefined || ts > s.lastInboundTs))
59
+ s.lastInboundTs = ts;
60
+ }
61
+ else if (m?.dir === "out") {
62
+ s.outboundCount++;
63
+ if (ts !== undefined && (s.lastOutboundTs === undefined || ts > s.lastOutboundTs))
64
+ s.lastOutboundTs = ts;
65
+ }
66
+ else {
67
+ continue; // not a message record
68
+ }
69
+ if (ts !== undefined && (s.firstMsgTs === undefined || ts < s.firstMsgTs))
70
+ s.firstMsgTs = ts;
71
+ }
72
+ byPeer.set(peer, s);
73
+ if (s.firstMsgTs !== undefined && (historySince === undefined || s.firstMsgTs < historySince)) {
74
+ historySince = s.firstMsgTs;
75
+ }
76
+ }
77
+ return { byPeer, historySince };
78
+ }
package/dist/cli.js CHANGED
@@ -171,6 +171,7 @@ async function main() {
171
171
  routesPath: resolve(args.configDir, "routes.yaml"),
172
172
  doraRosterPath,
173
173
  downloadsDir: resolve(args.configDir, "downloads"),
174
+ messagesPath: resolve(args.configDir, "messages.json"),
174
175
  callIce: {
175
176
  keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
176
177
  bootstrapNodes,
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.105";
1
+ window.__DK_UI_VERSION="0.1.108";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -501,6 +501,558 @@ window.__DK_UI_VERSION="0.1.105";
501
501
  DK_ME_FALLBACK,
502
502
  dkCopy
503
503
  });
504
+ var actions_default = {
505
+ version: 1,
506
+ scope: "assistant",
507
+ note: "The Assistant's action list. /help is generated from it, the router dispatches from it, and iOS/Android implement it. A later natural-language layer translates a sentence into one of these actions and nothing else. See docs/assistant/SPEC.md.",
508
+ actions: [
509
+ {
510
+ name: "help",
511
+ usage: "/help",
512
+ summary: "List what the Assistant can do.",
513
+ params: { type: "object", properties: {}, required: [] },
514
+ confirm: "none",
515
+ scope: "assistant"
516
+ },
517
+ {
518
+ name: "friends-list",
519
+ usage: "/friends list [filter\u2026]",
520
+ summary: "Show friends as a table: name, last heard from, messages in/out. With no filter, everyone.",
521
+ params: {
522
+ type: "object",
523
+ properties: {
524
+ filters: {
525
+ type: "array",
526
+ items: { enum: ["never-messaged", "never-replied", "silent", "pending"] },
527
+ description: "Several filters are OR'd together."
528
+ },
529
+ silentDays: { type: "integer", minimum: 1, default: 90, description: "Argument of the silent filter: nothing from them in this many days." }
530
+ },
531
+ required: []
532
+ },
533
+ confirm: "none",
534
+ scope: "assistant"
535
+ },
536
+ {
537
+ name: "friends-remove",
538
+ usage: "/friends remove <filter\u2026>",
539
+ summary: "Same table with a tick per row and a Remove button. Nothing is removed by typing alone.",
540
+ params: {
541
+ type: "object",
542
+ properties: {
543
+ filters: {
544
+ type: "array",
545
+ items: { enum: ["never-messaged", "never-replied", "silent", "pending"] },
546
+ minItems: 1
547
+ },
548
+ silentDays: { type: "integer", minimum: 1, default: 90 }
549
+ },
550
+ required: ["filters"]
551
+ },
552
+ confirm: "list",
553
+ scope: "assistant"
554
+ },
555
+ {
556
+ name: "find",
557
+ usage: "/find <text>",
558
+ summary: "Friends whose name, alias, description or ens name contains the text.",
559
+ params: {
560
+ type: "object",
561
+ properties: { text: { type: "string", minLength: 1 } },
562
+ required: ["text"]
563
+ },
564
+ confirm: "none",
565
+ scope: "assistant"
566
+ },
567
+ {
568
+ name: "rename",
569
+ usage: "/rename <friend> <new name>",
570
+ summary: "Set a friend's alias \u2014 what you see them as, on this device.",
571
+ params: {
572
+ type: "object",
573
+ properties: {
574
+ friend: { type: "string", description: "Matches name, alias or userid prefix. Ambiguous = nothing happens, the candidates are listed." },
575
+ alias: { type: "string", minLength: 1 }
576
+ },
577
+ required: ["friend", "alias"]
578
+ },
579
+ confirm: "none",
580
+ scope: "assistant"
581
+ },
582
+ {
583
+ name: "mute",
584
+ usage: "/mute <friend>",
585
+ summary: "Stop alerts from one conversation.",
586
+ params: {
587
+ type: "object",
588
+ properties: { friend: { type: "string" } },
589
+ required: ["friend"]
590
+ },
591
+ confirm: "none",
592
+ scope: "assistant"
593
+ },
594
+ {
595
+ name: "unmute",
596
+ usage: "/unmute <friend>",
597
+ summary: "Alerts from that conversation again.",
598
+ params: {
599
+ type: "object",
600
+ properties: { friend: { type: "string" } },
601
+ required: ["friend"]
602
+ },
603
+ confirm: "none",
604
+ scope: "assistant"
605
+ },
606
+ {
607
+ name: "sound",
608
+ usage: "/sound on|off",
609
+ summary: "Sound on a new message.",
610
+ params: {
611
+ type: "object",
612
+ properties: { on: { type: "boolean" } },
613
+ required: ["on"]
614
+ },
615
+ confirm: "none",
616
+ scope: "assistant"
617
+ },
618
+ {
619
+ name: "flash",
620
+ usage: "/flash on|off",
621
+ summary: "Flash the window on a new message.",
622
+ params: {
623
+ type: "object",
624
+ properties: { on: { type: "boolean" } },
625
+ required: ["on"]
626
+ },
627
+ confirm: "none",
628
+ scope: "assistant"
629
+ },
630
+ {
631
+ name: "status",
632
+ usage: "/status",
633
+ summary: "Friends, pending requests, how far back the history on this device goes, app version.",
634
+ params: { type: "object", properties: {}, required: [] },
635
+ confirm: "none",
636
+ scope: "assistant"
637
+ }
638
+ ],
639
+ filters: [
640
+ {
641
+ name: "never-messaged",
642
+ usage: "never-messaged",
643
+ summary: "No message either way.",
644
+ guarded: true
645
+ },
646
+ {
647
+ name: "never-replied",
648
+ usage: "never-replied",
649
+ summary: "You wrote at least once, they never did.",
650
+ guarded: true
651
+ },
652
+ {
653
+ name: "silent",
654
+ usage: "silent [days]",
655
+ summary: "Nothing from them for this many days (default 90).",
656
+ guarded: true,
657
+ arg: { name: "days", type: "integer", default: 90, minimum: 1 }
658
+ },
659
+ {
660
+ name: "pending",
661
+ usage: "pending",
662
+ summary: "A request you sent that was never accepted.",
663
+ guarded: false
664
+ }
665
+ ],
666
+ dropped: [
667
+ {
668
+ name: "never-called",
669
+ reason: "The message store holds no call records on any platform, so the predicate cannot be evaluated. /api/assistant/friends returns lastCallTs: null and the filter is not offered."
670
+ }
671
+ ],
672
+ guard: {
673
+ recentDays: 14,
674
+ applies: "Filters marked guarded never match a friend accepted (or, with no acceptance stamp, requested) less than recentDays ago; the result says how many were skipped."
675
+ }
676
+ };
677
+ var ASSISTANT_ID = "__assistant__";
678
+ var DAY_MS = 864e5;
679
+ var RECENT_DAYS = 14;
680
+ var DEFAULT_SILENT_DAYS = 90;
681
+ var FILTER_NAMES = ["never-messaged", "never-replied", "silent", "pending"];
682
+ var GUARDED = /* @__PURE__ */ new Set(["never-messaged", "never-replied", "silent"]);
683
+ function parseCommand(line) {
684
+ const text = String(line != null ? line : "").trim();
685
+ if (!text.startsWith("/"))
686
+ return null;
687
+ const parts = text.slice(1).split(/\s+/).filter(Boolean);
688
+ if (!parts.length)
689
+ return null;
690
+ const head = parts[0].toLowerCase();
691
+ if (head === "friends") {
692
+ const sub = (parts[1] || "").toLowerCase();
693
+ if (sub === "list" || sub === "remove")
694
+ return { ok: true, name: `friends-${sub}`, args: parts.slice(2) };
695
+ return { ok: false, name: "friends", args: parts.slice(1), error: "friends-subcommand" };
696
+ }
697
+ return { ok: true, name: head, args: parts.slice(1) };
698
+ }
699
+ function parseFilters(args) {
700
+ const filters = [];
701
+ const unknown = [];
702
+ let silentDays = DEFAULT_SILENT_DAYS;
703
+ const words = (args != null ? args : []).map((a) => String(a).toLowerCase());
704
+ for (let i = 0; i < words.length; i++) {
705
+ const w = words[i];
706
+ if (!FILTER_NAMES.includes(w)) {
707
+ unknown.push(words[i]);
708
+ continue;
709
+ }
710
+ if (w === "silent") {
711
+ const n = Number(words[i + 1]);
712
+ if (Number.isInteger(n) && n > 0) {
713
+ silentDays = n;
714
+ i++;
715
+ }
716
+ }
717
+ if (!filters.includes(w))
718
+ filters.push(w);
719
+ }
720
+ return { filters, silentDays, unknown };
721
+ }
722
+ var acceptedOrRequestedAt = (f) => f.acceptedAt || f.requestedAt || 0;
723
+ function isRecent(friend, now, recentDays = RECENT_DAYS) {
724
+ const at = acceptedOrRequestedAt(friend);
725
+ return at > 0 && now - at < recentDays * DAY_MS;
726
+ }
727
+ function matchesFilter(friend, filter, { now, silentDays = DEFAULT_SILENT_DAYS } = {}) {
728
+ const inCount = friend.inboundCount || 0;
729
+ const outCount = friend.outboundCount || 0;
730
+ switch (filter) {
731
+ case "never-messaged":
732
+ return inCount === 0 && outCount === 0;
733
+ case "never-replied":
734
+ return outCount > 0 && inCount === 0;
735
+ case "silent":
736
+ return !friend.lastInboundTs || now - friend.lastInboundTs > silentDays * DAY_MS;
737
+ case "pending":
738
+ return friend.pending === true;
739
+ default:
740
+ return false;
741
+ }
742
+ }
743
+ function selectFriends(friends, filters, { now = Date.now(), silentDays = DEFAULT_SILENT_DAYS, recentDays = RECENT_DAYS } = {}) {
744
+ const pool = (friends != null ? friends : []).filter((f) => f && f.userid !== ASSISTANT_ID && !f.pinned);
745
+ if (!filters || !filters.length)
746
+ return { matched: pool.slice(), skippedRecent: [] };
747
+ const matched = [];
748
+ const skippedRecent = [];
749
+ for (const f of pool) {
750
+ let hit = false;
751
+ let guardedOnly = false;
752
+ for (const filter of filters) {
753
+ if (!matchesFilter(f, filter, { now, silentDays }))
754
+ continue;
755
+ if (GUARDED.has(filter) && isRecent(f, now, recentDays)) {
756
+ guardedOnly = true;
757
+ continue;
758
+ }
759
+ hit = true;
760
+ break;
761
+ }
762
+ if (hit)
763
+ matched.push(f);
764
+ else if (guardedOnly)
765
+ skippedRecent.push(f);
766
+ }
767
+ return { matched, skippedRecent };
768
+ }
769
+ function findFriends(friends, text) {
770
+ const q = String(text != null ? text : "").trim().toLowerCase();
771
+ if (!q)
772
+ return [];
773
+ return (friends != null ? friends : []).filter((f) => f && f.userid !== ASSISTANT_ID).filter((f) => [f.name, f.alias, f.description, f.ens].some((v) => typeof v === "string" && v.toLowerCase().includes(q)));
774
+ }
775
+ function resolveFriend(friends, token) {
776
+ const q = String(token != null ? token : "").trim().toLowerCase();
777
+ if (!q)
778
+ return { match: null, candidates: [] };
779
+ const pool = (friends != null ? friends : []).filter((f) => f && f.userid !== ASSISTANT_ID);
780
+ const exact = pool.filter((f) => [f.name, f.alias].some((v) => typeof v === "string" && v.toLowerCase() === q));
781
+ if (exact.length === 1)
782
+ return { match: exact[0], candidates: exact };
783
+ if (exact.length > 1)
784
+ return { match: null, candidates: exact };
785
+ const partial = pool.filter((f) => [f.name, f.alias].some((v) => typeof v === "string" && v.toLowerCase().startsWith(q)) || typeof f.userid === "string" && f.userid.toLowerCase().startsWith(q));
786
+ if (partial.length === 1)
787
+ return { match: partial[0], candidates: partial };
788
+ return { match: null, candidates: partial };
789
+ }
790
+ var ASSISTANT_PEER = {
791
+ id: ASSISTANT_ID,
792
+ userId: ASSISTANT_ID,
793
+ alias: "Beagle Assistant",
794
+ address: "",
795
+ online: true,
796
+ app: true,
797
+ pinned: true,
798
+ agent: false,
799
+ pending: false,
800
+ via: null,
801
+ ping: null,
802
+ ip: "",
803
+ unread: 0,
804
+ lastMsg: "",
805
+ lastTime: "",
806
+ lastTs: 0,
807
+ lastDir: null,
808
+ acceptedAt: 0,
809
+ requestedAt: 0,
810
+ wire: "163",
811
+ avatarUrl: null,
812
+ punkId: null
813
+ };
814
+ var fmtDate = (ts, lang) => !ts ? lang === "zh" ? "\u4ECE\u672A" : "never" : new Date(ts).toLocaleDateString(lang === "zh" ? "zh-CN" : "en-GB", { year: "numeric", month: "short", day: "numeric" });
815
+ var fmtAgo = (ts, now, lang) => {
816
+ if (!ts)
817
+ return lang === "zh" ? "\u4ECE\u672A" : "never";
818
+ const days = Math.floor((now - ts) / 864e5);
819
+ if (days <= 0)
820
+ return lang === "zh" ? "\u4ECA\u5929" : "today";
821
+ if (days === 1)
822
+ return lang === "zh" ? "1 \u5929\u524D" : "1 day ago";
823
+ return lang === "zh" ? `${days} \u5929\u524D` : `${days} days ago`;
824
+ };
825
+ var nameOf = (f) => f.alias || f.name || f.userid;
826
+ function sinceLine(historySince, lang) {
827
+ if (!historySince)
828
+ return lang === "zh" ? "*\u57FA\u4E8E\u672C\u8BBE\u5907\u4E0A\u7684\u6D88\u606F\uFF08\u672C\u673A\u6682\u65E0\u5386\u53F2\u8BB0\u5F55\uFF09*" : "*based on messages on this device (no history here yet)*";
829
+ return lang === "zh" ? `*\u57FA\u4E8E\u672C\u8BBE\u5907\u81EA ${fmtDate(historySince, lang)} \u8D77\u7684\u6D88\u606F*` : `*based on messages on this device since ${fmtDate(historySince, lang)}*`;
830
+ }
831
+ function friendTable(rows, now, lang) {
832
+ return rows.map((f) => lang === "zh" ? `- **${nameOf(f)}** \u2014 \u6700\u540E\u6536\u5230 ${fmtAgo(f.lastInboundTs, now, lang)} \xB7 \u6536 ${f.inboundCount || 0} / \u53D1 ${f.outboundCount || 0}` : `- **${nameOf(f)}** \u2014 last heard ${fmtAgo(f.lastInboundTs, now, lang)} \xB7 in ${f.inboundCount || 0} / out ${f.outboundCount || 0}`).join("\n");
833
+ }
834
+ function helpText(lang) {
835
+ const intro = lang === "zh" ? "**Beagle \u52A9\u624B** \u2014 \u53EA\u5728\u672C\u673A\u8FD0\u884C\uFF0C\u4E0D\u4F1A\u628A\u4EFB\u4F55\u5185\u5BB9\u53D1\u5230\u7F51\u7EDC\u4E0A\u3002\n\n\u53EF\u7528\u547D\u4EE4\uFF1A" : "**Beagle Assistant** \u2014 runs on this machine only; nothing here is sent over the network.\n\nCommands:";
836
+ const cmds = actions_default.actions.map((a) => `- \`${a.usage}\` \u2014 ${a.summary}`).join("\n");
837
+ const filters = actions_default.filters.map((f) => `- \`${f.usage}\` \u2014 ${f.summary}`).join("\n");
838
+ const tail = lang === "zh" ? `\u7B5B\u9009\u6761\u4EF6\uFF08\u53EF\u7EC4\u5408\uFF0C\u53D6\u5E76\u96C6\uFF09\uFF1A
839
+ ${filters}
840
+
841
+ \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):
842
+ ${filters}
843
+
844
+ Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters. Removal always asks first.`;
845
+ return `${intro}
846
+ ${cmds}
847
+
848
+ ${tail}`;
849
+ }
850
+ var seq = 0;
851
+ var asMsg = (from, text, extra) => Object.assign({
852
+ id: `as-${Date.now()}-${seq++}`,
853
+ from,
854
+ time: (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
855
+ text
856
+ }, extra);
857
+ function AssistantPanel({ m, T, lang, onRemove }) {
858
+ const rows = m.assistant.rows || [];
859
+ const [sel, setSel] = React.useState(() => new Set(rows.map((r) => r.userid)));
860
+ const [busy, setBusy] = React.useState(false);
861
+ const [done, setDone] = React.useState(null);
862
+ if (!rows.length)
863
+ return null;
864
+ const toggle = (id) => setSel((s) => {
865
+ const n = new Set(s);
866
+ n.has(id) ? n.delete(id) : n.add(id);
867
+ return n;
868
+ });
869
+ const label = lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
870
+ 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: {
871
+ display: "flex",
872
+ alignItems: "center",
873
+ gap: 8,
874
+ padding: "7px 10px",
875
+ borderBottom: "1px solid var(--line)",
876
+ fontSize: 13,
877
+ cursor: done ? "default" : "pointer"
878
+ } }, /* @__PURE__ */ React.createElement(
879
+ "input",
880
+ {
881
+ type: "checkbox",
882
+ checked: sel.has(r.userid),
883
+ disabled: !!done || busy,
884
+ onChange: () => toggle(r.userid)
885
+ }
886
+ ), /* @__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: () => {
887
+ setBusy(true);
888
+ onRemove([...sel]).then((r) => {
889
+ 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` : ""}`);
890
+ }).finally(() => setBusy(false));
891
+ } }, 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"))));
892
+ }
893
+ function useAssistant({ lang, T, muted, onToggleMute, sound, flash, setAlert, onReloadFriends }) {
894
+ const [thread, setThread] = React.useState(() => [{ day: (/* @__PURE__ */ new Date()).toLocaleDateString() }, asMsg("them", helpText(lang))]);
895
+ const append = React.useCallback((...ms) => setThread((t) => t.concat(ms)), []);
896
+ React.useEffect(() => {
897
+ setThread((t) => t.length === 2 ? [t[0], asMsg("them", helpText(lang))] : t);
898
+ }, [lang]);
899
+ const fetchFriends = async () => {
900
+ const r = await fetch("/api/assistant/friends").then((x) => x.json());
901
+ return { friends: r.friends || [], historySince: r.historySince || null, now: r.now || Date.now(), ver: r.beagleVer || "" };
902
+ };
903
+ const listReply = (rows, skipped, data, title) => {
904
+ if (!rows.length) {
905
+ return `${title}
906
+
907
+ ${lang === "zh" ? "\u6CA1\u6709\u7B26\u5408\u7684\u597D\u53CB\u3002" : "No friends match."}
908
+
909
+ ${sinceLine(data.historySince, lang)}`;
910
+ }
911
+ const skip = skipped.length ? lang === "zh" ? `
912
+
913
+ *\u8DF3\u8FC7\u4E86 ${skipped.length} \u4F4D\u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u3002*` : `
914
+
915
+ *${skipped.length} friend${skipped.length > 1 ? "s" : ""} added in the last ${RECENT_DAYS} days ${skipped.length > 1 ? "were" : "was"} skipped.*` : "";
916
+ return `${title}
917
+
918
+ ${friendTable(rows, data.now, lang)}${skip}
919
+
920
+ ${sinceLine(data.historySince, lang)}`;
921
+ };
922
+ const ambiguous = (cands) => lang === "zh" ? `\u6709 ${cands.length} \u4F4D\u597D\u53CB\u7B26\u5408\uFF0C\u8BF7\u8BF4\u5F97\u66F4\u5177\u4F53\u4E00\u4E9B\uFF1A
923
+ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}` : `That matches ${cands.length} friends \u2014 be more specific:
924
+ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
925
+ const notFound = (tok) => lang === "zh" ? `\u6CA1\u6709\u627E\u5230\u597D\u53CB "${tok}"\u3002` : `No friend matches "${tok}".`;
926
+ const handle = React.useCallback(async (text) => {
927
+ append(asMsg("me", text, { status: "read" }));
928
+ const cmd = parseCommand(text);
929
+ if (!cmd) {
930
+ return append(asMsg("them", lang === "zh" ? "\u6211\u53EA\u8BA4\u8BC6\u4EE5 `/` \u5F00\u5934\u7684\u547D\u4EE4\u3002\u8F93\u5165 `/help` \u770B\u770B\u80FD\u505A\u4EC0\u4E48\u3002" : "I only understand commands starting with `/`. Type `/help` to see them."));
931
+ }
932
+ if (cmd.ok === false) {
933
+ return append(asMsg("them", lang === "zh" ? "`/friends` \u9700\u8981 `list` \u6216 `remove`\uFF0C\u4F8B\u5982 `/friends list silent 90`\u3002" : "`/friends` needs `list` or `remove`, for example `/friends list silent 90`."));
934
+ }
935
+ try {
936
+ switch (cmd.name) {
937
+ case "help":
938
+ return append(asMsg("them", helpText(lang)));
939
+ case "status": {
940
+ const d = await fetchFriends();
941
+ const pending = d.friends.filter((f) => f.pending).length;
942
+ return append(asMsg("them", lang === "zh" ? `**\u72B6\u6001**
943
+
944
+ - \u597D\u53CB\uFF1A${d.friends.length}
945
+ - \u5F85\u5BF9\u65B9\u63A5\u53D7\uFF1A${pending}
946
+ - \u672C\u673A\u5386\u53F2\u8D77\u81EA\uFF1A${fmtDate(d.historySince, lang)}
947
+ - \u7248\u672C\uFF1A${d.ver}` : `**Status**
948
+
949
+ - Friends: ${d.friends.length}
950
+ - Waiting to be accepted: ${pending}
951
+ - History on this device since: ${fmtDate(d.historySince, lang)}
952
+ - Version: ${d.ver}`));
953
+ }
954
+ case "friends-list": {
955
+ const { filters, silentDays, unknown } = parseFilters(cmd.args);
956
+ if (unknown.length) {
957
+ return append(asMsg("them", lang === "zh" ? `\u4E0D\u8BA4\u8BC6\u7684\u7B5B\u9009\u6761\u4EF6\uFF1A${unknown.join(", ")}\u3002\u53EF\u7528\uFF1A${actions_default.filters.map((f) => f.name).join(", ")}\u3002` : `I don't know the filter${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}. Try: ${actions_default.filters.map((f) => f.name).join(", ")}.`));
958
+ }
959
+ const d = await fetchFriends();
960
+ const { matched, skippedRecent } = selectFriends(d.friends, filters, { now: d.now, silentDays });
961
+ const title = filters.length ? `**${filters.join(" + ")}**${filters.includes("silent") ? ` (${silentDays}d)` : ""} \u2014 ${matched.length}` : lang === "zh" ? `**\u5168\u90E8\u597D\u53CB** \u2014 ${matched.length}` : `**All friends** \u2014 ${matched.length}`;
962
+ return append(asMsg("them", listReply(matched, skippedRecent, d, title)));
963
+ }
964
+ case "friends-remove": {
965
+ const { filters, silentDays, unknown } = parseFilters(cmd.args);
966
+ if (unknown.length || !filters.length) {
967
+ return append(asMsg("them", lang === "zh" ? `\u5220\u9664\u9700\u8981\u7B5B\u9009\u6761\u4EF6\uFF0C\u4F8B\u5982 \`/friends remove never-messaged\`\u3002\u53EF\u7528\uFF1A${actions_default.filters.map((f) => f.name).join(", ")}\u3002` : `Removing needs a filter, for example \`/friends remove never-messaged\`. Available: ${actions_default.filters.map((f) => f.name).join(", ")}.`));
968
+ }
969
+ const d = await fetchFriends();
970
+ const { matched, skippedRecent } = selectFriends(d.friends, filters, { now: d.now, silentDays });
971
+ const title = lang === "zh" ? `**\u5C06\u8981\u5220\u9664 ${matched.length} \u4F4D\u597D\u53CB** \u2014 \u8BF7\u5148\u6838\u5BF9\u8FD9\u4EFD\u540D\u5355` : `**${matched.length} friend${matched.length === 1 ? "" : "s"} to remove** \u2014 check the list first`;
972
+ return append(asMsg(
973
+ "them",
974
+ listReply(matched, skippedRecent, d, title),
975
+ matched.length ? { assistant: { kind: "confirm", rows: matched } } : void 0
976
+ ));
977
+ }
978
+ case "find": {
979
+ const q = cmd.args.join(" ");
980
+ if (!q)
981
+ return append(asMsg("them", lang === "zh" ? "\u7528\u6CD5\uFF1A`/find <\u6587\u5B57>`" : "Usage: `/find <text>`"));
982
+ const d = await fetchFriends();
983
+ const rows = findFriends(d.friends, q);
984
+ const title = lang === "zh" ? `**"${q}"** \u2014 ${rows.length} \u4F4D` : `**"${q}"** \u2014 ${rows.length} found`;
985
+ return append(asMsg("them", listReply(rows, [], d, title)));
986
+ }
987
+ case "rename": {
988
+ const [tok, ...rest] = cmd.args;
989
+ const alias = rest.join(" ").trim();
990
+ if (!tok || !alias)
991
+ return append(asMsg("them", lang === "zh" ? "\u7528\u6CD5\uFF1A`/rename <\u597D\u53CB> <\u65B0\u540D\u5B57>`" : "Usage: `/rename <friend> <new name>`"));
992
+ const d = await fetchFriends();
993
+ const { match, candidates } = resolveFriend(d.friends, tok);
994
+ if (!match)
995
+ return append(asMsg("them", candidates.length ? ambiguous(candidates) : notFound(tok)));
996
+ const was = nameOf(match);
997
+ await dkApi.alias(match.userid, alias);
998
+ if (onReloadFriends)
999
+ onReloadFriends();
1000
+ return append(asMsg("them", lang === "zh" ? `\u5DF2\u628A "${was}" \u6539\u540D\u4E3A "${alias}"\u3002` : `Renamed "${was}" to "${alias}".`));
1001
+ }
1002
+ case "mute":
1003
+ case "unmute": {
1004
+ const tok = cmd.args.join(" ").trim();
1005
+ if (!tok)
1006
+ return append(asMsg("them", `${lang === "zh" ? "\u7528\u6CD5" : "Usage"}: \`/${cmd.name} <friend>\``));
1007
+ const d = await fetchFriends();
1008
+ const { match, candidates } = resolveFriend(d.friends, tok);
1009
+ if (!match)
1010
+ return append(asMsg("them", candidates.length ? ambiguous(candidates) : notFound(tok)));
1011
+ const isMuted = (muted || []).includes(match.userid);
1012
+ const want = cmd.name === "mute";
1013
+ if (isMuted !== want)
1014
+ onToggleMute(match.userid);
1015
+ return append(asMsg("them", want ? lang === "zh" ? `\u5DF2\u9759\u97F3 "${nameOf(match)}"\u3002` : `Muted "${nameOf(match)}".` : lang === "zh" ? `\u5DF2\u53D6\u6D88\u9759\u97F3 "${nameOf(match)}"\u3002` : `Unmuted "${nameOf(match)}".`));
1016
+ }
1017
+ case "sound":
1018
+ case "flash": {
1019
+ const word = (cmd.args[0] || "").toLowerCase();
1020
+ if (word !== "on" && word !== "off") {
1021
+ return append(asMsg("them", `${lang === "zh" ? "\u7528\u6CD5" : "Usage"}: \`/${cmd.name} on|off\``));
1022
+ }
1023
+ const on = word === "on";
1024
+ setAlert(cmd.name, on);
1025
+ const what = cmd.name === "sound" ? lang === "zh" ? "\u63D0\u793A\u97F3" : "Sound" : lang === "zh" ? "\u7A97\u53E3\u95EA\u70C1" : "Window flash";
1026
+ return append(asMsg("them", lang === "zh" ? `${what}\u5DF2${on ? "\u5F00\u542F" : "\u5173\u95ED"}\u3002` : `${what} ${on ? "on" : "off"}.`));
1027
+ }
1028
+ default:
1029
+ return append(asMsg("them", lang === "zh" ? `\u4E0D\u8BA4\u8BC6\u7684\u547D\u4EE4 \`/${cmd.name}\`\u3002\u8F93\u5165 \`/help\` \u770B\u770B\u53EF\u7528\u547D\u4EE4\u3002` : `I don't know \`/${cmd.name}\`. Type \`/help\` for the list.`));
1030
+ }
1031
+ } catch (e) {
1032
+ return append(asMsg("them", lang === "zh" ? `\u51FA\u9519\u4E86\uFF1A${e && e.message || e}` : `That failed: ${e && e.message || e}`));
1033
+ }
1034
+ }, [append, lang, muted, onToggleMute, setAlert, onReloadFriends]);
1035
+ const removeFriends = React.useCallback(async (ids) => {
1036
+ let removed = 0;
1037
+ const failed = [];
1038
+ for (const id of ids) {
1039
+ try {
1040
+ const r = await dkApi.remove(id);
1041
+ if (r && r.ok === false)
1042
+ failed.push(id);
1043
+ else
1044
+ removed++;
1045
+ } catch (e) {
1046
+ failed.push(id);
1047
+ }
1048
+ }
1049
+ if (onReloadFriends)
1050
+ onReloadFriends();
1051
+ 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"}.`));
1052
+ return { removed, failed: failed.length };
1053
+ }, [append, lang, onReloadFriends]);
1054
+ return { thread, handle, removeFriends };
1055
+ }
504
1056
  function RequestsBlock({ T, requests, onAct }) {
505
1057
  const [acted, setActed] = React.useState({});
506
1058
  const [failed, setFailed] = React.useState({});
@@ -581,7 +1133,7 @@ window.__DK_UI_VERSION="0.1.105";
581
1133
  whiteSpace: "nowrap",
582
1134
  overflow: "hidden",
583
1135
  textOverflow: "ellipsis"
584
- } }, peer.alias || shortKey(peer.userId, 8, 5)), peer.agent && /* @__PURE__ */ React.createElement(Icon, { name: "bot", size: 13, color: "var(--faint)", stroke: 2 }), muted && /* @__PURE__ */ React.createElement(Icon, { name: "bellOff", size: 12, color: "var(--faint)", stroke: 2, title: T && T.mutedTag || "sound muted" }), peer.wire === "64" && /* @__PURE__ */ React.createElement(Tag, { tone: "off", style: { height: 15, fontSize: 9.5, padding: "0 4px" } }, "w64")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 2 } }, peer.ip ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, peer.ip), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)", fontSize: 11 } }, "\xB7")) : null, /* @__PURE__ */ React.createElement("span", { style: {
1136
+ } }, peer.alias || shortKey(peer.userId, 8, 5)), peer.agent && /* @__PURE__ */ React.createElement(Icon, { name: "bot", size: 13, color: "var(--faint)", stroke: 2 }), peer.app && /* @__PURE__ */ React.createElement(Tag, { tone: "accent", style: { height: 15, fontSize: 9.5, padding: "0 4px" } }, "APP"), muted && /* @__PURE__ */ React.createElement(Icon, { name: "bellOff", size: 12, color: "var(--faint)", stroke: 2, title: T && T.mutedTag || "sound muted" }), peer.wire === "64" && /* @__PURE__ */ React.createElement(Tag, { tone: "off", style: { height: 15, fontSize: 9.5, padding: "0 4px" } }, "w64")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 2 } }, peer.ip ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, peer.ip), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)", fontSize: 11 } }, "\xB7")) : null, /* @__PURE__ */ React.createElement("span", { style: {
585
1137
  fontSize: 11,
586
1138
  color: "var(--dim)",
587
1139
  whiteSpace: "nowrap",
@@ -637,6 +1189,8 @@ window.__DK_UI_VERSION="0.1.105";
637
1189
  const s = (p.alias || "") + " " + p.userId + " " + p.ip;
638
1190
  return s.toLowerCase().includes(q.toLowerCase());
639
1191
  }).slice().sort((a, b) => {
1192
+ if (!!a.pinned !== !!b.pinned)
1193
+ return a.pinned ? -1 : 1;
640
1194
  const ta = dkActivityTs(a), tb = dkActivityTs(b);
641
1195
  if (ta !== tb)
642
1196
  return tb - ta;
@@ -1302,7 +1856,7 @@ window.__DK_UI_VERSION="0.1.105";
1302
1856
  return null;
1303
1857
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
1304
1858
  }
1305
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, answered, selMode, selected, onToggleSel, busy, isGroup }) {
1859
+ function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, answered, selMode, selected, onToggleSel, busy, isGroup }) {
1306
1860
  const mine = m.from === "me";
1307
1861
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
1308
1862
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -1631,7 +2185,7 @@ window.__DK_UI_VERSION="0.1.105";
1631
2185
  letterSpacing: -0.1,
1632
2186
  wordBreak: "break-word"
1633
2187
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
1634
- })(), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), /* @__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), (() => {
2188
+ })(), 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), (() => {
1635
2189
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
1636
2190
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
1637
2191
  if (!via)
@@ -1676,7 +2230,7 @@ window.__DK_UI_VERSION="0.1.105";
1676
2230
  ) : 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)" })))
1677
2231
  );
1678
2232
  }
1679
- function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, muted, onToggleMute, phone, onBack }) {
2233
+ function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted, onToggleMute, phone, onBack }) {
1680
2234
  const scrollRef = React.useRef(null);
1681
2235
  const prependRef = React.useRef(null);
1682
2236
  const [earlierBusy, setEarlierBusy] = React.useState(false);
@@ -1981,7 +2535,11 @@ window.__DK_UI_VERSION="0.1.105";
1981
2535
  style: { width: 40, height: 44, flexShrink: 0, border: "none", background: "transparent", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0 }
1982
2536
  },
1983
2537
  /* @__PURE__ */ React.createElement(Icon, { name: "chevronLeft", size: 26, stroke: 2.2, color: "var(--accent)" })
1984
- ), /* @__PURE__ */ React.createElement("div", { onClick: () => setPubProfile(true), title: T && T.pubOpen || "View public profile", style: { cursor: "pointer", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 })), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: phone ? 1 : void 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement("span", { onClick: () => setPubProfile(true), style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)", cursor: "pointer", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 } }, peer.alias || shortKey(peer.userId, phone ? 6 : 10, phone ? 4 : 6)), peer.agent && !phone && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), isGroup && !phone && /* @__PURE__ */ React.createElement(Tag, null, T && T.groupTag || "group"), !phone && /* @__PURE__ */ React.createElement(RouteTag, { peer })), 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(
2538
+ ), /* @__PURE__ */ React.createElement("div", { onClick: () => setPubProfile(true), title: T && T.pubOpen || "View public profile", style: { cursor: "pointer", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 })), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: phone ? 1 : void 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement("span", { onClick: () => setPubProfile(true), style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)", cursor: "pointer", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 } }, peer.alias || shortKey(peer.userId, phone ? 6 : 10, phone ? 4 : 6)), peer.app && !phone && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "APP"), peer.agent && !phone && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), isGroup && !phone && /* @__PURE__ */ React.createElement(Tag, null, T && T.groupTag || "group"), !phone && !peer.app && /* @__PURE__ */ React.createElement(RouteTag, { peer })), peer.app ? (
2539
+ // The internal id is not an address anyone can use and not a name
2540
+ // anyone chose; saying what this row IS is more use than showing it.
2541
+ /* @__PURE__ */ React.createElement("div", { style: { marginTop: 2, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, T && T.assistantSub || "Local \xB7 APP")
2542
+ ) : 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(
1985
2543
  Mono,
1986
2544
  {
1987
2545
  size: 11.5,
@@ -1999,7 +2557,7 @@ ${peer.address}`
1999
2557
  title: muted ? T && T.unmuteSound || "Turn sound back on for this chat" : T && T.muteSound || "Mute sound for this chat",
2000
2558
  onClick: () => onToggleMute(peer.id)
2001
2559
  }
2002
- ), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), onSendRtcFile && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
2560
+ ), onCall && !peer.app && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && !peer.app && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), onSendRtcFile && !peer.app && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
2003
2561
  "input",
2004
2562
  {
2005
2563
  ref: rtcFileRef,
@@ -2013,19 +2571,19 @@ ${peer.address}`
2013
2571
  ), !phone && /* @__PURE__ */ React.createElement(Btn, { icon: "file", title: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => rtcFileRef.current && rtcFileRef.current.click() })), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, phone && onToggleMute && /* @__PURE__ */ React.createElement(MenuItem, { icon: muted ? "bell" : "bellOff", label: muted ? T && T.unmuteSound || "Turn sound back on" : T && T.muteSound || "Mute sound", onClick: () => {
2014
2572
  setMenu(false);
2015
2573
  onToggleMute(peer.id);
2016
- } }), phone && onSendRtcFile && /* @__PURE__ */ React.createElement(MenuItem, { icon: "file", label: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => {
2574
+ } }), phone && onSendRtcFile && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "file", label: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => {
2017
2575
  setMenu(false);
2018
2576
  rtcFileRef.current && rtcFileRef.current.click();
2019
- } }), phone && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
2577
+ } }), phone && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
2020
2578
  setMenu(false);
2021
2579
  onOpenNet(peer);
2022
- } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
2580
+ } }), !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
2023
2581
  setMenu(false);
2024
2582
  onAlias(peer);
2025
2583
  } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
2026
2584
  setMenu(false);
2027
2585
  setSelMode(true);
2028
- } }), /* @__PURE__ */ React.createElement("div", { style: { height: 1, background: "var(--line)", margin: "5px 4px" } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "trash", label: T.remove, danger: true, onClick: () => {
2586
+ } }), !peer.app && /* @__PURE__ */ React.createElement("div", { style: { height: 1, background: "var(--line)", margin: "5px 4px" } }), !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "trash", label: T.remove, danger: true, onClick: () => {
2029
2587
  setMenu(false);
2030
2588
  onRemove(peer);
2031
2589
  } }))))),
@@ -2047,8 +2605,10 @@ ${peer.address}`
2047
2605
  m,
2048
2606
  peer,
2049
2607
  T,
2608
+ lang,
2050
2609
  isGroup,
2051
2610
  onTheater: setTheater,
2611
+ onAssistantRemove,
2052
2612
  onDelete: (id) => doDelete([id]),
2053
2613
  onCancel: doCancel,
2054
2614
  onRetry: doRetry,
@@ -2097,7 +2657,7 @@ ${peer.address}`
2097
2657
  e.target.value = "";
2098
2658
  }
2099
2659
  }
2100
- ), /* @__PURE__ */ React.createElement(Btn, { icon: "paperclip", title: T.sendFile, onClick: () => fileRef.current && fileRef.current.click() }), /* @__PURE__ */ React.createElement(
2660
+ ), !peer.app && /* @__PURE__ */ React.createElement(Btn, { icon: "paperclip", title: T.sendFile, onClick: () => fileRef.current && fileRef.current.click() }), /* @__PURE__ */ React.createElement(
2101
2661
  "input",
2102
2662
  {
2103
2663
  value: draft,
@@ -2200,12 +2760,12 @@ ${peer.address}`
2200
2760
  function ChatEmpty({ T }) {
2201
2761
  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));
2202
2762
  }
2203
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, prefillAddr, onPrefillConsumed, muted, onToggleMute, phone, onBack }) {
2763
+ 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 }) {
2204
2764
  const peer = peers.find((p) => p.id === activeId);
2205
2765
  if (phone) {
2206
- return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, 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 });
2766
+ 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 });
2207
2767
  }
2208
- 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, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
2768
+ 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 }));
2209
2769
  }
2210
2770
  Object.assign(window, { ChatTab });
2211
2771
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -4968,6 +5528,7 @@ ${peer.address}`
4968
5528
  autoAcceptLabel: "Auto-accept friend requests",
4969
5529
  autoAcceptSub: "Off: requests wait in the sidebar for your approval.",
4970
5530
  groupSenderUnknown: "The group relays only this display name. Register Beagle as a group agent (/agent add <beagle address> in the group) to receive full member identities you can add as friends.",
5531
+ assistantSub: "Local \xB7 APP",
4971
5532
  loadEarlier: "Load earlier messages",
4972
5533
  loadingEarlier: "Loading\u2026",
4973
5534
  pendingFriend: "Waiting for them to accept your friend request \u2014 messages will queue and deliver once they do.",
@@ -5162,6 +5723,7 @@ ${peer.address}`
5162
5723
  autoAcceptLabel: "\u81EA\u52A8\u63A5\u53D7\u597D\u53CB\u8BF7\u6C42",
5163
5724
  autoAcceptSub: "\u5173\u95ED\u540E\uFF0C\u597D\u53CB\u8BF7\u6C42\u4F1A\u7B49\u5F85\u4F60\u5728\u4FA7\u8FB9\u680F\u624B\u52A8\u786E\u8BA4\u3002",
5164
5725
  groupSenderUnknown: "\u7FA4\u670D\u52A1\u53EA\u8F6C\u53D1\u4E86\u663E\u793A\u540D\uFF0C\u62FF\u4E0D\u5230\u8BE5\u6210\u5458\u7684\u5B8C\u6574\u8EAB\u4EFD\u3002\u5728\u7FA4\u91CC\u6267\u884C /agent add <beagle \u5730\u5740> \u628A Beagle \u6CE8\u518C\u4E3A\u7FA4 agent \u540E\uFF0C\u5373\u53EF\u6536\u5230\u5B8C\u6574\u8EAB\u4EFD\u5E76\u4E00\u952E\u52A0\u597D\u53CB\u3002",
5726
+ assistantSub: "\u672C\u673A \xB7 APP",
5165
5727
  loadEarlier: "\u52A0\u8F7D\u66F4\u65E9\u7684\u6D88\u606F",
5166
5728
  loadingEarlier: "\u52A0\u8F7D\u4E2D\u2026",
5167
5729
  pendingFriend: "\u7B49\u5F85\u5BF9\u65B9\u63A5\u53D7\u597D\u53CB\u8BF7\u6C42 \u2014 \u6D88\u606F\u4F1A\u5148\u6392\u961F\uFF0C\u5BF9\u65B9\u63A5\u53D7\u540E\u81EA\u52A8\u9001\u8FBE\u3002",
@@ -5508,7 +6070,7 @@ ${peer.address}`
5508
6070
  sessionStorage.setItem(key, "1");
5509
6071
  window.location.reload();
5510
6072
  }, [me && me.beagleVer]);
5511
- const peers = data.peers;
6073
+ const peers = React.useMemo(() => [ASSISTANT_PEER].concat(data.peers || []), [data.peers]);
5512
6074
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
5513
6075
  React.useEffect(() => {
5514
6076
  const onHash = () => {
@@ -5572,6 +6134,16 @@ ${peer.address}`
5572
6134
  flash: t.alertFlash !== false,
5573
6135
  muted: mutedIds
5574
6136
  });
6137
+ const assistant = useAssistant({
6138
+ lang: t.lang,
6139
+ T,
6140
+ muted: mutedIds,
6141
+ onToggleMute,
6142
+ sound: t.alertSound !== false,
6143
+ flash: t.alertFlash !== false,
6144
+ setAlert: (which, on) => setTweak(which === "sound" ? "alertSound" : "alertFlash", on),
6145
+ onReloadFriends: () => data.refresh()
6146
+ });
5575
6147
  const callCtl = useCallController(me.userId, (peerId, video, direction) => {
5576
6148
  const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
5577
6149
  dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
@@ -5634,6 +6206,8 @@ ${peer.address}`
5634
6206
  React.useEffect(() => {
5635
6207
  if (!activeId)
5636
6208
  return;
6209
+ if (activeId === ASSISTANT_ID)
6210
+ return;
5637
6211
  data.loadThread(activeId, { reset: true });
5638
6212
  dkApi.markRead(activeId).then(data.refresh);
5639
6213
  const iv = setInterval(() => {
@@ -5677,6 +6251,10 @@ ${peer.address}`
5677
6251
  const onSend = (text) => {
5678
6252
  if (!activeId || !text || !text.trim())
5679
6253
  return;
6254
+ if (activeId === ASSISTANT_ID) {
6255
+ assistant.handle(text.trim());
6256
+ return;
6257
+ }
5680
6258
  dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
5681
6259
  };
5682
6260
  const peerTakesRtcFile = (id) => {
@@ -5784,7 +6362,7 @@ ${peer.address}`
5784
6362
  fontWeight: 600,
5785
6363
  background: tab === n.id ? "var(--accent)" : "transparent",
5786
6364
  color: tab === n.id ? "#fff" : "var(--dim)"
5787
- } }, /* @__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: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && data.earlier[activeId]), 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) => {
6365
+ } }, /* @__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) => {
5788
6366
  data.refresh();
5789
6367
  return r;
5790
6368
  }) })), 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(
@@ -162,6 +162,12 @@ export class MessageStore {
162
162
  arr = [];
163
163
  this.byPeer.set(peer, arr);
164
164
  }
165
+ // ts is the paging cursor (`before` is strict), so it must be unique per
166
+ // thread: a burst in one millisecond would otherwise be split by a page
167
+ // boundary and the rest of it skipped. Order is kept; the id is not touched.
168
+ const newest = arr.length ? arr[arr.length - 1].ts : -Infinity;
169
+ if (msg.ts <= newest)
170
+ msg.ts = newest + 1;
165
171
  arr.push(msg);
166
172
  if (arr.length > MAX_PER_PEER)
167
173
  arr.splice(0, arr.length - MAX_PER_PEER);
package/dist/server.d.ts CHANGED
@@ -24,6 +24,10 @@ export interface BeagleServerOptions {
24
24
  /** Directory where the daemon saves received files (<configDir>/downloads).
25
25
  * Used to serve GET /api/file-download. Undefined disables downloads. */
26
26
  downloadsDir?: string;
27
+ /** <configDir>/messages.json — the chat store BOTH backends write. Read
28
+ * (never written) by GET /api/assistant/friends to count a friend's
29
+ * history without hauling it over IPC. Undefined = no counts. */
30
+ messagesPath?: string;
27
31
  /** The swappable backend, so the UI can show which one is live and drive the
28
32
  * LAN handoff. Optional: a caller that pins one backend just omits it and
29
33
  * the network panel falls back to "no virtual LAN here". */
package/dist/server.js CHANGED
@@ -24,6 +24,7 @@ import { DEFAULT_EXITS } from "./exits.js";
24
24
  import * as peerAddr from "@decentnetwork/peer";
25
25
  import { bridgeRoster } from "./bridge-roster.js";
26
26
  import { isSupervised } from "./relaunch.js";
27
+ import { historyStats } from "./assistant-stats.js";
27
28
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
28
29
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
29
30
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -2237,6 +2238,58 @@ export function startBeagleServer(opts) {
2237
2238
  sendJson(res, 200, { me, peers, requests, exits, activeExit });
2238
2239
  return;
2239
2240
  }
2241
+ // Everything the Assistant's friend filters need, in one answer: the
2242
+ // friend list plus how much was said to each friend and when. The
2243
+ // counts come from the store file in our own config dir — see
2244
+ // assistant-stats.ts for why they are not fetched over IPC.
2245
+ if (req.method === "GET" && url === "/api/assistant/friends") {
2246
+ const [flist, ensPub] = await Promise.all([
2247
+ opts.call({ op: "friends-list" }),
2248
+ ensPublicByUserid(300),
2249
+ ]);
2250
+ const stamps = friendStoreStamps(opts.callIce?.keyFile);
2251
+ const stats = historyStats(opts.messagesPath);
2252
+ const fl = flist.ok ? (flist.data?.friends ?? []) : [];
2253
+ const friends = fl.map((f) => {
2254
+ const uid = f.userid ?? "";
2255
+ const stamp = stamps.get(uid);
2256
+ const pub = ensPub.get(uid);
2257
+ const status = f.status;
2258
+ const acceptedAt = f.lastSeen ?? stamp?.acceptedAt ?? 0;
2259
+ const realName = typeof f.name === "string" && f.name !== uid ? f.name : undefined;
2260
+ const s = stats.byPeer.get(uid);
2261
+ return {
2262
+ userid: uid,
2263
+ name: f.alias || realName || pub?.displayName || uid,
2264
+ alias: f.alias || null,
2265
+ description: f.descr || pub?.description || null,
2266
+ ens: pub?.ens ?? null,
2267
+ acceptedAt,
2268
+ requestedAt: f.requestedAt ?? stamp?.requestedAt ?? 0,
2269
+ // A request we sent that was never accepted — same rule the
2270
+ // sidebar's waiting banner uses.
2271
+ pending: f.accepted === false || (status === "requested" && !acceptedAt),
2272
+ firstMsgTs: s?.firstMsgTs ?? null,
2273
+ lastInboundTs: s?.lastInboundTs ?? null,
2274
+ lastOutboundTs: s?.lastOutboundTs ?? null,
2275
+ inboundCount: s?.inboundCount ?? 0,
2276
+ outboundCount: s?.outboundCount ?? 0,
2277
+ // No call records exist in the store, so this is always null and
2278
+ // the Assistant does not offer a never-called filter.
2279
+ lastCallTs: null,
2280
+ // Only ever true if a backend starts saying so; nothing does today.
2281
+ agent: f.agent === true,
2282
+ };
2283
+ });
2284
+ sendJson(res, 200, {
2285
+ ok: true,
2286
+ friends,
2287
+ historySince: stats.historySince ?? null,
2288
+ beagleVer: beagleVersion(),
2289
+ now: Date.now(),
2290
+ });
2291
+ return;
2292
+ }
2240
2293
  if (req.method === "GET" && url === "/api/chat-history") {
2241
2294
  const q = new URL(req.url || "/", "http://x").searchParams;
2242
2295
  const peer = q.get("peer") || undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.105",
3
+ "version": "0.1.108",
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",
@@ -28,14 +28,14 @@
28
28
  "dependencies": {
29
29
  "@decentnetwork/chat-components": "^0.1.3",
30
30
  "@decentnetwork/lan": "^0.1.283",
31
- "@decentnetwork/peer": "^0.1.166",
31
+ "@decentnetwork/peer": "^0.1.168",
32
32
  "@decentnetwork/peer-webrtc": "^0.2.16",
33
33
  "js-yaml": "^4.1.0",
34
34
  "ws": "^8.21.1",
35
35
  "yargs": "^17.7.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@decentnetwork/beagle-ui": "^0.2.20",
38
+ "@decentnetwork/beagle-ui": "^0.2.22",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",