@decentnetwork/beagle 0.1.105 → 0.1.107

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.107";
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,7 @@ 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 })), 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
2539
  Mono,
1986
2540
  {
1987
2541
  size: 11.5,
@@ -2047,8 +2601,10 @@ ${peer.address}`
2047
2601
  m,
2048
2602
  peer,
2049
2603
  T,
2604
+ lang,
2050
2605
  isGroup,
2051
2606
  onTheater: setTheater,
2607
+ onAssistantRemove,
2052
2608
  onDelete: (id) => doDelete([id]),
2053
2609
  onCancel: doCancel,
2054
2610
  onRetry: doRetry,
@@ -2200,12 +2756,12 @@ ${peer.address}`
2200
2756
  function ChatEmpty({ T }) {
2201
2757
  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
2758
  }
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 }) {
2759
+ 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
2760
  const peer = peers.find((p) => p.id === activeId);
2205
2761
  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 });
2762
+ 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
2763
  }
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 }));
2764
+ 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
2765
  }
2210
2766
  Object.assign(window, { ChatTab });
2211
2767
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -5508,7 +6064,7 @@ ${peer.address}`
5508
6064
  sessionStorage.setItem(key, "1");
5509
6065
  window.location.reload();
5510
6066
  }, [me && me.beagleVer]);
5511
- const peers = data.peers;
6067
+ const peers = React.useMemo(() => [ASSISTANT_PEER].concat(data.peers || []), [data.peers]);
5512
6068
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
5513
6069
  React.useEffect(() => {
5514
6070
  const onHash = () => {
@@ -5572,6 +6128,16 @@ ${peer.address}`
5572
6128
  flash: t.alertFlash !== false,
5573
6129
  muted: mutedIds
5574
6130
  });
6131
+ const assistant = useAssistant({
6132
+ lang: t.lang,
6133
+ T,
6134
+ muted: mutedIds,
6135
+ onToggleMute,
6136
+ sound: t.alertSound !== false,
6137
+ flash: t.alertFlash !== false,
6138
+ setAlert: (which, on) => setTweak(which === "sound" ? "alertSound" : "alertFlash", on),
6139
+ onReloadFriends: () => data.refresh()
6140
+ });
5575
6141
  const callCtl = useCallController(me.userId, (peerId, video, direction) => {
5576
6142
  const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
5577
6143
  dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
@@ -5634,6 +6200,8 @@ ${peer.address}`
5634
6200
  React.useEffect(() => {
5635
6201
  if (!activeId)
5636
6202
  return;
6203
+ if (activeId === ASSISTANT_ID)
6204
+ return;
5637
6205
  data.loadThread(activeId, { reset: true });
5638
6206
  dkApi.markRead(activeId).then(data.refresh);
5639
6207
  const iv = setInterval(() => {
@@ -5677,6 +6245,10 @@ ${peer.address}`
5677
6245
  const onSend = (text) => {
5678
6246
  if (!activeId || !text || !text.trim())
5679
6247
  return;
6248
+ if (activeId === ASSISTANT_ID) {
6249
+ assistant.handle(text.trim());
6250
+ return;
6251
+ }
5680
6252
  dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
5681
6253
  };
5682
6254
  const peerTakesRtcFile = (id) => {
@@ -5784,7 +6356,7 @@ ${peer.address}`
5784
6356
  fontWeight: 600,
5785
6357
  background: tab === n.id ? "var(--accent)" : "transparent",
5786
6358
  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) => {
6359
+ } }, /* @__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
6360
  data.refresh();
5789
6361
  return r;
5790
6362
  }) })), 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.107",
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.21",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",