@decentnetwork/beagle-ui 0.2.20 → 0.2.22

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.
Files changed (2) hide show
  1. package/dist/beagle-ui.js +601 -17
  2. package/package.json +1 -1
package/dist/beagle-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- export const BEAGLE_UI_VERSION = "0.2.20";
1
+ export const BEAGLE_UI_VERSION = "0.2.22";
2
2
  // src/icons.jsx
3
3
  var ICON_PATHS = {
4
4
  // ---- tab bar (the four must feel like one set) ----
@@ -506,6 +506,564 @@ Object.assign(window, {
506
506
  dkCopy
507
507
  });
508
508
 
509
+ // src/assistant/actions.json
510
+ var actions_default = {
511
+ version: 1,
512
+ scope: "assistant",
513
+ 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.",
514
+ actions: [
515
+ {
516
+ name: "help",
517
+ usage: "/help",
518
+ summary: "List what the Assistant can do.",
519
+ params: { type: "object", properties: {}, required: [] },
520
+ confirm: "none",
521
+ scope: "assistant"
522
+ },
523
+ {
524
+ name: "friends-list",
525
+ usage: "/friends list [filter\u2026]",
526
+ summary: "Show friends as a table: name, last heard from, messages in/out. With no filter, everyone.",
527
+ params: {
528
+ type: "object",
529
+ properties: {
530
+ filters: {
531
+ type: "array",
532
+ items: { enum: ["never-messaged", "never-replied", "silent", "pending"] },
533
+ description: "Several filters are OR'd together."
534
+ },
535
+ silentDays: { type: "integer", minimum: 1, default: 90, description: "Argument of the silent filter: nothing from them in this many days." }
536
+ },
537
+ required: []
538
+ },
539
+ confirm: "none",
540
+ scope: "assistant"
541
+ },
542
+ {
543
+ name: "friends-remove",
544
+ usage: "/friends remove <filter\u2026>",
545
+ summary: "Same table with a tick per row and a Remove button. Nothing is removed by typing alone.",
546
+ params: {
547
+ type: "object",
548
+ properties: {
549
+ filters: {
550
+ type: "array",
551
+ items: { enum: ["never-messaged", "never-replied", "silent", "pending"] },
552
+ minItems: 1
553
+ },
554
+ silentDays: { type: "integer", minimum: 1, default: 90 }
555
+ },
556
+ required: ["filters"]
557
+ },
558
+ confirm: "list",
559
+ scope: "assistant"
560
+ },
561
+ {
562
+ name: "find",
563
+ usage: "/find <text>",
564
+ summary: "Friends whose name, alias, description or ens name contains the text.",
565
+ params: {
566
+ type: "object",
567
+ properties: { text: { type: "string", minLength: 1 } },
568
+ required: ["text"]
569
+ },
570
+ confirm: "none",
571
+ scope: "assistant"
572
+ },
573
+ {
574
+ name: "rename",
575
+ usage: "/rename <friend> <new name>",
576
+ summary: "Set a friend's alias \u2014 what you see them as, on this device.",
577
+ params: {
578
+ type: "object",
579
+ properties: {
580
+ friend: { type: "string", description: "Matches name, alias or userid prefix. Ambiguous = nothing happens, the candidates are listed." },
581
+ alias: { type: "string", minLength: 1 }
582
+ },
583
+ required: ["friend", "alias"]
584
+ },
585
+ confirm: "none",
586
+ scope: "assistant"
587
+ },
588
+ {
589
+ name: "mute",
590
+ usage: "/mute <friend>",
591
+ summary: "Stop alerts from one conversation.",
592
+ params: {
593
+ type: "object",
594
+ properties: { friend: { type: "string" } },
595
+ required: ["friend"]
596
+ },
597
+ confirm: "none",
598
+ scope: "assistant"
599
+ },
600
+ {
601
+ name: "unmute",
602
+ usage: "/unmute <friend>",
603
+ summary: "Alerts from that conversation again.",
604
+ params: {
605
+ type: "object",
606
+ properties: { friend: { type: "string" } },
607
+ required: ["friend"]
608
+ },
609
+ confirm: "none",
610
+ scope: "assistant"
611
+ },
612
+ {
613
+ name: "sound",
614
+ usage: "/sound on|off",
615
+ summary: "Sound on a new message.",
616
+ params: {
617
+ type: "object",
618
+ properties: { on: { type: "boolean" } },
619
+ required: ["on"]
620
+ },
621
+ confirm: "none",
622
+ scope: "assistant"
623
+ },
624
+ {
625
+ name: "flash",
626
+ usage: "/flash on|off",
627
+ summary: "Flash the window on a new message.",
628
+ params: {
629
+ type: "object",
630
+ properties: { on: { type: "boolean" } },
631
+ required: ["on"]
632
+ },
633
+ confirm: "none",
634
+ scope: "assistant"
635
+ },
636
+ {
637
+ name: "status",
638
+ usage: "/status",
639
+ summary: "Friends, pending requests, how far back the history on this device goes, app version.",
640
+ params: { type: "object", properties: {}, required: [] },
641
+ confirm: "none",
642
+ scope: "assistant"
643
+ }
644
+ ],
645
+ filters: [
646
+ {
647
+ name: "never-messaged",
648
+ usage: "never-messaged",
649
+ summary: "No message either way.",
650
+ guarded: true
651
+ },
652
+ {
653
+ name: "never-replied",
654
+ usage: "never-replied",
655
+ summary: "You wrote at least once, they never did.",
656
+ guarded: true
657
+ },
658
+ {
659
+ name: "silent",
660
+ usage: "silent [days]",
661
+ summary: "Nothing from them for this many days (default 90).",
662
+ guarded: true,
663
+ arg: { name: "days", type: "integer", default: 90, minimum: 1 }
664
+ },
665
+ {
666
+ name: "pending",
667
+ usage: "pending",
668
+ summary: "A request you sent that was never accepted.",
669
+ guarded: false
670
+ }
671
+ ],
672
+ dropped: [
673
+ {
674
+ name: "never-called",
675
+ 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."
676
+ }
677
+ ],
678
+ guard: {
679
+ recentDays: 14,
680
+ 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."
681
+ }
682
+ };
683
+
684
+ // src/assistant/filters.js
685
+ var ASSISTANT_ID = "__assistant__";
686
+ var DAY_MS = 864e5;
687
+ var RECENT_DAYS = 14;
688
+ var DEFAULT_SILENT_DAYS = 90;
689
+ var FILTER_NAMES = ["never-messaged", "never-replied", "silent", "pending"];
690
+ var GUARDED = /* @__PURE__ */ new Set(["never-messaged", "never-replied", "silent"]);
691
+ function parseCommand(line) {
692
+ const text = String(line != null ? line : "").trim();
693
+ if (!text.startsWith("/"))
694
+ return null;
695
+ const parts = text.slice(1).split(/\s+/).filter(Boolean);
696
+ if (!parts.length)
697
+ return null;
698
+ const head = parts[0].toLowerCase();
699
+ if (head === "friends") {
700
+ const sub = (parts[1] || "").toLowerCase();
701
+ if (sub === "list" || sub === "remove")
702
+ return { ok: true, name: `friends-${sub}`, args: parts.slice(2) };
703
+ return { ok: false, name: "friends", args: parts.slice(1), error: "friends-subcommand" };
704
+ }
705
+ return { ok: true, name: head, args: parts.slice(1) };
706
+ }
707
+ function parseFilters(args) {
708
+ const filters = [];
709
+ const unknown = [];
710
+ let silentDays = DEFAULT_SILENT_DAYS;
711
+ const words = (args != null ? args : []).map((a) => String(a).toLowerCase());
712
+ for (let i = 0; i < words.length; i++) {
713
+ const w = words[i];
714
+ if (!FILTER_NAMES.includes(w)) {
715
+ unknown.push(words[i]);
716
+ continue;
717
+ }
718
+ if (w === "silent") {
719
+ const n = Number(words[i + 1]);
720
+ if (Number.isInteger(n) && n > 0) {
721
+ silentDays = n;
722
+ i++;
723
+ }
724
+ }
725
+ if (!filters.includes(w))
726
+ filters.push(w);
727
+ }
728
+ return { filters, silentDays, unknown };
729
+ }
730
+ var acceptedOrRequestedAt = (f) => f.acceptedAt || f.requestedAt || 0;
731
+ function isRecent(friend, now, recentDays = RECENT_DAYS) {
732
+ const at = acceptedOrRequestedAt(friend);
733
+ return at > 0 && now - at < recentDays * DAY_MS;
734
+ }
735
+ function matchesFilter(friend, filter, { now, silentDays = DEFAULT_SILENT_DAYS } = {}) {
736
+ const inCount = friend.inboundCount || 0;
737
+ const outCount = friend.outboundCount || 0;
738
+ switch (filter) {
739
+ case "never-messaged":
740
+ return inCount === 0 && outCount === 0;
741
+ case "never-replied":
742
+ return outCount > 0 && inCount === 0;
743
+ case "silent":
744
+ return !friend.lastInboundTs || now - friend.lastInboundTs > silentDays * DAY_MS;
745
+ case "pending":
746
+ return friend.pending === true;
747
+ default:
748
+ return false;
749
+ }
750
+ }
751
+ function selectFriends(friends, filters, { now = Date.now(), silentDays = DEFAULT_SILENT_DAYS, recentDays = RECENT_DAYS } = {}) {
752
+ const pool = (friends != null ? friends : []).filter((f) => f && f.userid !== ASSISTANT_ID && !f.pinned);
753
+ if (!filters || !filters.length)
754
+ return { matched: pool.slice(), skippedRecent: [] };
755
+ const matched = [];
756
+ const skippedRecent = [];
757
+ for (const f of pool) {
758
+ let hit = false;
759
+ let guardedOnly = false;
760
+ for (const filter of filters) {
761
+ if (!matchesFilter(f, filter, { now, silentDays }))
762
+ continue;
763
+ if (GUARDED.has(filter) && isRecent(f, now, recentDays)) {
764
+ guardedOnly = true;
765
+ continue;
766
+ }
767
+ hit = true;
768
+ break;
769
+ }
770
+ if (hit)
771
+ matched.push(f);
772
+ else if (guardedOnly)
773
+ skippedRecent.push(f);
774
+ }
775
+ return { matched, skippedRecent };
776
+ }
777
+ function findFriends(friends, text) {
778
+ const q = String(text != null ? text : "").trim().toLowerCase();
779
+ if (!q)
780
+ return [];
781
+ 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)));
782
+ }
783
+ function resolveFriend(friends, token) {
784
+ const q = String(token != null ? token : "").trim().toLowerCase();
785
+ if (!q)
786
+ return { match: null, candidates: [] };
787
+ const pool = (friends != null ? friends : []).filter((f) => f && f.userid !== ASSISTANT_ID);
788
+ const exact = pool.filter((f) => [f.name, f.alias].some((v) => typeof v === "string" && v.toLowerCase() === q));
789
+ if (exact.length === 1)
790
+ return { match: exact[0], candidates: exact };
791
+ if (exact.length > 1)
792
+ return { match: null, candidates: exact };
793
+ 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));
794
+ if (partial.length === 1)
795
+ return { match: partial[0], candidates: partial };
796
+ return { match: null, candidates: partial };
797
+ }
798
+
799
+ // src/assistant.jsx
800
+ var ASSISTANT_PEER = {
801
+ id: ASSISTANT_ID,
802
+ userId: ASSISTANT_ID,
803
+ alias: "Beagle Assistant",
804
+ address: "",
805
+ online: true,
806
+ app: true,
807
+ pinned: true,
808
+ agent: false,
809
+ pending: false,
810
+ via: null,
811
+ ping: null,
812
+ ip: "",
813
+ unread: 0,
814
+ lastMsg: "",
815
+ lastTime: "",
816
+ lastTs: 0,
817
+ lastDir: null,
818
+ acceptedAt: 0,
819
+ requestedAt: 0,
820
+ wire: "163",
821
+ avatarUrl: null,
822
+ punkId: null
823
+ };
824
+ 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" });
825
+ var fmtAgo = (ts, now, lang) => {
826
+ if (!ts)
827
+ return lang === "zh" ? "\u4ECE\u672A" : "never";
828
+ const days = Math.floor((now - ts) / 864e5);
829
+ if (days <= 0)
830
+ return lang === "zh" ? "\u4ECA\u5929" : "today";
831
+ if (days === 1)
832
+ return lang === "zh" ? "1 \u5929\u524D" : "1 day ago";
833
+ return lang === "zh" ? `${days} \u5929\u524D` : `${days} days ago`;
834
+ };
835
+ var nameOf = (f) => f.alias || f.name || f.userid;
836
+ function sinceLine(historySince, lang) {
837
+ if (!historySince)
838
+ 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)*";
839
+ 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)}*`;
840
+ }
841
+ function friendTable(rows, now, lang) {
842
+ 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");
843
+ }
844
+ function helpText(lang) {
845
+ 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:";
846
+ const cmds = actions_default.actions.map((a) => `- \`${a.usage}\` \u2014 ${a.summary}`).join("\n");
847
+ const filters = actions_default.filters.map((f) => `- \`${f.usage}\` \u2014 ${f.summary}`).join("\n");
848
+ const tail = lang === "zh" ? `\u7B5B\u9009\u6761\u4EF6\uFF08\u53EF\u7EC4\u5408\uFF0C\u53D6\u5E76\u96C6\uFF09\uFF1A
849
+ ${filters}
850
+
851
+ \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):
852
+ ${filters}
853
+
854
+ Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters. Removal always asks first.`;
855
+ return `${intro}
856
+ ${cmds}
857
+
858
+ ${tail}`;
859
+ }
860
+ var seq = 0;
861
+ var asMsg = (from, text, extra) => Object.assign({
862
+ id: `as-${Date.now()}-${seq++}`,
863
+ from,
864
+ time: (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
865
+ text
866
+ }, extra);
867
+ function AssistantPanel({ m, T, lang, onRemove }) {
868
+ const rows = m.assistant.rows || [];
869
+ const [sel, setSel] = React.useState(() => new Set(rows.map((r) => r.userid)));
870
+ const [busy, setBusy] = React.useState(false);
871
+ const [done, setDone] = React.useState(null);
872
+ if (!rows.length)
873
+ return null;
874
+ const toggle = (id) => setSel((s) => {
875
+ const n = new Set(s);
876
+ n.has(id) ? n.delete(id) : n.add(id);
877
+ return n;
878
+ });
879
+ const label = lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
880
+ 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: {
881
+ display: "flex",
882
+ alignItems: "center",
883
+ gap: 8,
884
+ padding: "7px 10px",
885
+ borderBottom: "1px solid var(--line)",
886
+ fontSize: 13,
887
+ cursor: done ? "default" : "pointer"
888
+ } }, /* @__PURE__ */ React.createElement(
889
+ "input",
890
+ {
891
+ type: "checkbox",
892
+ checked: sel.has(r.userid),
893
+ disabled: !!done || busy,
894
+ onChange: () => toggle(r.userid)
895
+ }
896
+ ), /* @__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: () => {
897
+ setBusy(true);
898
+ onRemove([...sel]).then((r) => {
899
+ 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` : ""}`);
900
+ }).finally(() => setBusy(false));
901
+ } }, 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"))));
902
+ }
903
+ function useAssistant({ lang, T, muted, onToggleMute, sound, flash, setAlert, onReloadFriends }) {
904
+ const [thread, setThread] = React.useState(() => [{ day: (/* @__PURE__ */ new Date()).toLocaleDateString() }, asMsg("them", helpText(lang))]);
905
+ const append = React.useCallback((...ms) => setThread((t) => t.concat(ms)), []);
906
+ React.useEffect(() => {
907
+ setThread((t) => t.length === 2 ? [t[0], asMsg("them", helpText(lang))] : t);
908
+ }, [lang]);
909
+ const fetchFriends = async () => {
910
+ const r = await fetch("/api/assistant/friends").then((x) => x.json());
911
+ return { friends: r.friends || [], historySince: r.historySince || null, now: r.now || Date.now(), ver: r.beagleVer || "" };
912
+ };
913
+ const listReply = (rows, skipped, data, title) => {
914
+ if (!rows.length) {
915
+ return `${title}
916
+
917
+ ${lang === "zh" ? "\u6CA1\u6709\u7B26\u5408\u7684\u597D\u53CB\u3002" : "No friends match."}
918
+
919
+ ${sinceLine(data.historySince, lang)}`;
920
+ }
921
+ const skip = skipped.length ? lang === "zh" ? `
922
+
923
+ *\u8DF3\u8FC7\u4E86 ${skipped.length} \u4F4D\u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u3002*` : `
924
+
925
+ *${skipped.length} friend${skipped.length > 1 ? "s" : ""} added in the last ${RECENT_DAYS} days ${skipped.length > 1 ? "were" : "was"} skipped.*` : "";
926
+ return `${title}
927
+
928
+ ${friendTable(rows, data.now, lang)}${skip}
929
+
930
+ ${sinceLine(data.historySince, lang)}`;
931
+ };
932
+ const ambiguous = (cands) => lang === "zh" ? `\u6709 ${cands.length} \u4F4D\u597D\u53CB\u7B26\u5408\uFF0C\u8BF7\u8BF4\u5F97\u66F4\u5177\u4F53\u4E00\u4E9B\uFF1A
933
+ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}` : `That matches ${cands.length} friends \u2014 be more specific:
934
+ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
935
+ const notFound = (tok) => lang === "zh" ? `\u6CA1\u6709\u627E\u5230\u597D\u53CB "${tok}"\u3002` : `No friend matches "${tok}".`;
936
+ const handle = React.useCallback(async (text) => {
937
+ append(asMsg("me", text, { status: "read" }));
938
+ const cmd = parseCommand(text);
939
+ if (!cmd) {
940
+ 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."));
941
+ }
942
+ if (cmd.ok === false) {
943
+ 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`."));
944
+ }
945
+ try {
946
+ switch (cmd.name) {
947
+ case "help":
948
+ return append(asMsg("them", helpText(lang)));
949
+ case "status": {
950
+ const d = await fetchFriends();
951
+ const pending = d.friends.filter((f) => f.pending).length;
952
+ return append(asMsg("them", lang === "zh" ? `**\u72B6\u6001**
953
+
954
+ - \u597D\u53CB\uFF1A${d.friends.length}
955
+ - \u5F85\u5BF9\u65B9\u63A5\u53D7\uFF1A${pending}
956
+ - \u672C\u673A\u5386\u53F2\u8D77\u81EA\uFF1A${fmtDate(d.historySince, lang)}
957
+ - \u7248\u672C\uFF1A${d.ver}` : `**Status**
958
+
959
+ - Friends: ${d.friends.length}
960
+ - Waiting to be accepted: ${pending}
961
+ - History on this device since: ${fmtDate(d.historySince, lang)}
962
+ - Version: ${d.ver}`));
963
+ }
964
+ case "friends-list": {
965
+ const { filters, silentDays, unknown } = parseFilters(cmd.args);
966
+ if (unknown.length) {
967
+ 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(", ")}.`));
968
+ }
969
+ const d = await fetchFriends();
970
+ const { matched, skippedRecent } = selectFriends(d.friends, filters, { now: d.now, silentDays });
971
+ 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}`;
972
+ return append(asMsg("them", listReply(matched, skippedRecent, d, title)));
973
+ }
974
+ case "friends-remove": {
975
+ const { filters, silentDays, unknown } = parseFilters(cmd.args);
976
+ if (unknown.length || !filters.length) {
977
+ 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(", ")}.`));
978
+ }
979
+ const d = await fetchFriends();
980
+ const { matched, skippedRecent } = selectFriends(d.friends, filters, { now: d.now, silentDays });
981
+ 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`;
982
+ return append(asMsg(
983
+ "them",
984
+ listReply(matched, skippedRecent, d, title),
985
+ matched.length ? { assistant: { kind: "confirm", rows: matched } } : void 0
986
+ ));
987
+ }
988
+ case "find": {
989
+ const q = cmd.args.join(" ");
990
+ if (!q)
991
+ return append(asMsg("them", lang === "zh" ? "\u7528\u6CD5\uFF1A`/find <\u6587\u5B57>`" : "Usage: `/find <text>`"));
992
+ const d = await fetchFriends();
993
+ const rows = findFriends(d.friends, q);
994
+ const title = lang === "zh" ? `**"${q}"** \u2014 ${rows.length} \u4F4D` : `**"${q}"** \u2014 ${rows.length} found`;
995
+ return append(asMsg("them", listReply(rows, [], d, title)));
996
+ }
997
+ case "rename": {
998
+ const [tok, ...rest] = cmd.args;
999
+ const alias = rest.join(" ").trim();
1000
+ if (!tok || !alias)
1001
+ return append(asMsg("them", lang === "zh" ? "\u7528\u6CD5\uFF1A`/rename <\u597D\u53CB> <\u65B0\u540D\u5B57>`" : "Usage: `/rename <friend> <new name>`"));
1002
+ const d = await fetchFriends();
1003
+ const { match, candidates } = resolveFriend(d.friends, tok);
1004
+ if (!match)
1005
+ return append(asMsg("them", candidates.length ? ambiguous(candidates) : notFound(tok)));
1006
+ const was = nameOf(match);
1007
+ await dkApi.alias(match.userid, alias);
1008
+ if (onReloadFriends)
1009
+ onReloadFriends();
1010
+ return append(asMsg("them", lang === "zh" ? `\u5DF2\u628A "${was}" \u6539\u540D\u4E3A "${alias}"\u3002` : `Renamed "${was}" to "${alias}".`));
1011
+ }
1012
+ case "mute":
1013
+ case "unmute": {
1014
+ const tok = cmd.args.join(" ").trim();
1015
+ if (!tok)
1016
+ return append(asMsg("them", `${lang === "zh" ? "\u7528\u6CD5" : "Usage"}: \`/${cmd.name} <friend>\``));
1017
+ const d = await fetchFriends();
1018
+ const { match, candidates } = resolveFriend(d.friends, tok);
1019
+ if (!match)
1020
+ return append(asMsg("them", candidates.length ? ambiguous(candidates) : notFound(tok)));
1021
+ const isMuted = (muted || []).includes(match.userid);
1022
+ const want = cmd.name === "mute";
1023
+ if (isMuted !== want)
1024
+ onToggleMute(match.userid);
1025
+ 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)}".`));
1026
+ }
1027
+ case "sound":
1028
+ case "flash": {
1029
+ const word = (cmd.args[0] || "").toLowerCase();
1030
+ if (word !== "on" && word !== "off") {
1031
+ return append(asMsg("them", `${lang === "zh" ? "\u7528\u6CD5" : "Usage"}: \`/${cmd.name} on|off\``));
1032
+ }
1033
+ const on = word === "on";
1034
+ setAlert(cmd.name, on);
1035
+ const what = cmd.name === "sound" ? lang === "zh" ? "\u63D0\u793A\u97F3" : "Sound" : lang === "zh" ? "\u7A97\u53E3\u95EA\u70C1" : "Window flash";
1036
+ return append(asMsg("them", lang === "zh" ? `${what}\u5DF2${on ? "\u5F00\u542F" : "\u5173\u95ED"}\u3002` : `${what} ${on ? "on" : "off"}.`));
1037
+ }
1038
+ default:
1039
+ 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.`));
1040
+ }
1041
+ } catch (e) {
1042
+ return append(asMsg("them", lang === "zh" ? `\u51FA\u9519\u4E86\uFF1A${e && e.message || e}` : `That failed: ${e && e.message || e}`));
1043
+ }
1044
+ }, [append, lang, muted, onToggleMute, setAlert, onReloadFriends]);
1045
+ const removeFriends = React.useCallback(async (ids) => {
1046
+ let removed = 0;
1047
+ const failed = [];
1048
+ for (const id of ids) {
1049
+ try {
1050
+ const r = await dkApi.remove(id);
1051
+ if (r && r.ok === false)
1052
+ failed.push(id);
1053
+ else
1054
+ removed++;
1055
+ } catch (e) {
1056
+ failed.push(id);
1057
+ }
1058
+ }
1059
+ if (onReloadFriends)
1060
+ onReloadFriends();
1061
+ 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"}.`));
1062
+ return { removed, failed: failed.length };
1063
+ }, [append, lang, onReloadFriends]);
1064
+ return { thread, handle, removeFriends };
1065
+ }
1066
+
509
1067
  // src/chat.jsx
510
1068
  function RequestsBlock({ T, requests, onAct }) {
511
1069
  const [acted, setActed] = React.useState({});
@@ -587,7 +1145,7 @@ function PeerRow({ peer, T, active, onClick, muted }) {
587
1145
  whiteSpace: "nowrap",
588
1146
  overflow: "hidden",
589
1147
  textOverflow: "ellipsis"
590
- } }, 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: {
1148
+ } }, 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: {
591
1149
  fontSize: 11,
592
1150
  color: "var(--dim)",
593
1151
  whiteSpace: "nowrap",
@@ -643,6 +1201,8 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd, pre
643
1201
  const s = (p.alias || "") + " " + p.userId + " " + p.ip;
644
1202
  return s.toLowerCase().includes(q.toLowerCase());
645
1203
  }).slice().sort((a, b) => {
1204
+ if (!!a.pinned !== !!b.pinned)
1205
+ return a.pinned ? -1 : 1;
646
1206
  const ta = dkActivityTs(a), tb = dkActivityTs(b);
647
1207
  if (ta !== tb)
648
1208
  return tb - ta;
@@ -1308,7 +1868,7 @@ function DkChatForm({ form, submitted, peer, onSubmit }) {
1308
1868
  return null;
1309
1869
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
1310
1870
  }
1311
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, answered, selMode, selected, onToggleSel, busy, isGroup }) {
1871
+ function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, answered, selMode, selected, onToggleSel, busy, isGroup }) {
1312
1872
  const mine = m.from === "me";
1313
1873
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
1314
1874
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -1637,7 +2197,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onRetryMsg, o
1637
2197
  letterSpacing: -0.1,
1638
2198
  wordBreak: "break-word"
1639
2199
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
1640
- })(), 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), (() => {
2200
+ })(), 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), (() => {
1641
2201
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
1642
2202
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
1643
2203
  if (!via)
@@ -1682,7 +2242,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onRetryMsg, o
1682
2242
  ) : 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)" })))
1683
2243
  );
1684
2244
  }
1685
- function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, muted, onToggleMute, phone, onBack }) {
2245
+ function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted, onToggleMute, phone, onBack }) {
1686
2246
  const scrollRef = React.useRef(null);
1687
2247
  const prependRef = React.useRef(null);
1688
2248
  const [earlierBusy, setEarlierBusy] = React.useState(false);
@@ -1987,7 +2547,11 @@ function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, on
1987
2547
  style: { width: 40, height: 44, flexShrink: 0, border: "none", background: "transparent", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0 }
1988
2548
  },
1989
2549
  /* @__PURE__ */ React.createElement(Icon, { name: "chevronLeft", size: 26, stroke: 2.2, color: "var(--accent)" })
1990
- ), /* @__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(
2550
+ ), /* @__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 ? (
2551
+ // The internal id is not an address anyone can use and not a name
2552
+ // anyone chose; saying what this row IS is more use than showing it.
2553
+ /* @__PURE__ */ React.createElement("div", { style: { marginTop: 2, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, T && T.assistantSub || "Local \xB7 APP")
2554
+ ) : 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(
1991
2555
  Mono,
1992
2556
  {
1993
2557
  size: 11.5,
@@ -2005,7 +2569,7 @@ ${peer.address}`
2005
2569
  title: muted ? T && T.unmuteSound || "Turn sound back on for this chat" : T && T.muteSound || "Mute sound for this chat",
2006
2570
  onClick: () => onToggleMute(peer.id)
2007
2571
  }
2008
- ), 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(
2572
+ ), 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(
2009
2573
  "input",
2010
2574
  {
2011
2575
  ref: rtcFileRef,
@@ -2019,19 +2583,19 @@ ${peer.address}`
2019
2583
  ), !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: () => {
2020
2584
  setMenu(false);
2021
2585
  onToggleMute(peer.id);
2022
- } }), phone && onSendRtcFile && /* @__PURE__ */ React.createElement(MenuItem, { icon: "file", label: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => {
2586
+ } }), phone && onSendRtcFile && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "file", label: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => {
2023
2587
  setMenu(false);
2024
2588
  rtcFileRef.current && rtcFileRef.current.click();
2025
- } }), phone && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
2589
+ } }), phone && !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "network", label: T && T.openNet || "Network view", onClick: () => {
2026
2590
  setMenu(false);
2027
2591
  onOpenNet(peer);
2028
- } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
2592
+ } }), !peer.app && /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
2029
2593
  setMenu(false);
2030
2594
  onAlias(peer);
2031
2595
  } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
2032
2596
  setMenu(false);
2033
2597
  setSelMode(true);
2034
- } }), /* @__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: () => {
2598
+ } }), !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: () => {
2035
2599
  setMenu(false);
2036
2600
  onRemove(peer);
2037
2601
  } }))))),
@@ -2053,8 +2617,10 @@ ${peer.address}`
2053
2617
  m,
2054
2618
  peer,
2055
2619
  T,
2620
+ lang,
2056
2621
  isGroup,
2057
2622
  onTheater: setTheater,
2623
+ onAssistantRemove,
2058
2624
  onDelete: (id) => doDelete([id]),
2059
2625
  onCancel: doCancel,
2060
2626
  onRetry: doRetry,
@@ -2103,7 +2669,7 @@ ${peer.address}`
2103
2669
  e.target.value = "";
2104
2670
  }
2105
2671
  }
2106
- ), /* @__PURE__ */ React.createElement(Btn, { icon: "paperclip", title: T.sendFile, onClick: () => fileRef.current && fileRef.current.click() }), /* @__PURE__ */ React.createElement(
2672
+ ), !peer.app && /* @__PURE__ */ React.createElement(Btn, { icon: "paperclip", title: T.sendFile, onClick: () => fileRef.current && fileRef.current.click() }), /* @__PURE__ */ React.createElement(
2107
2673
  "input",
2108
2674
  {
2109
2675
  value: draft,
@@ -2206,12 +2772,12 @@ function MenuItem({ icon, label, onClick, danger }) {
2206
2772
  function ChatEmpty({ T }) {
2207
2773
  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));
2208
2774
  }
2209
- 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 }) {
2775
+ 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 }) {
2210
2776
  const peer = peers.find((p) => p.id === activeId);
2211
2777
  if (phone) {
2212
- 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 });
2778
+ 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 });
2213
2779
  }
2214
- 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 }));
2780
+ 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 }));
2215
2781
  }
2216
2782
  Object.assign(window, { ChatTab });
2217
2783
 
@@ -4996,6 +5562,7 @@ var STR = {
4996
5562
  autoAcceptLabel: "Auto-accept friend requests",
4997
5563
  autoAcceptSub: "Off: requests wait in the sidebar for your approval.",
4998
5564
  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.",
5565
+ assistantSub: "Local \xB7 APP",
4999
5566
  loadEarlier: "Load earlier messages",
5000
5567
  loadingEarlier: "Loading\u2026",
5001
5568
  pendingFriend: "Waiting for them to accept your friend request \u2014 messages will queue and deliver once they do.",
@@ -5190,6 +5757,7 @@ var STR = {
5190
5757
  autoAcceptLabel: "\u81EA\u52A8\u63A5\u53D7\u597D\u53CB\u8BF7\u6C42",
5191
5758
  autoAcceptSub: "\u5173\u95ED\u540E\uFF0C\u597D\u53CB\u8BF7\u6C42\u4F1A\u7B49\u5F85\u4F60\u5728\u4FA7\u8FB9\u680F\u624B\u52A8\u786E\u8BA4\u3002",
5192
5759
  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",
5760
+ assistantSub: "\u672C\u673A \xB7 APP",
5193
5761
  loadEarlier: "\u52A0\u8F7D\u66F4\u65E9\u7684\u6D88\u606F",
5194
5762
  loadingEarlier: "\u52A0\u8F7D\u4E2D\u2026",
5195
5763
  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",
@@ -5536,7 +6104,7 @@ function DkApp() {
5536
6104
  sessionStorage.setItem(key, "1");
5537
6105
  window.location.reload();
5538
6106
  }, [me && me.beagleVer]);
5539
- const peers = data.peers;
6107
+ const peers = React.useMemo(() => [ASSISTANT_PEER].concat(data.peers || []), [data.peers]);
5540
6108
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
5541
6109
  React.useEffect(() => {
5542
6110
  const onHash = () => {
@@ -5600,6 +6168,16 @@ function DkApp() {
5600
6168
  flash: t.alertFlash !== false,
5601
6169
  muted: mutedIds
5602
6170
  });
6171
+ const assistant = useAssistant({
6172
+ lang: t.lang,
6173
+ T,
6174
+ muted: mutedIds,
6175
+ onToggleMute,
6176
+ sound: t.alertSound !== false,
6177
+ flash: t.alertFlash !== false,
6178
+ setAlert: (which, on) => setTweak(which === "sound" ? "alertSound" : "alertFlash", on),
6179
+ onReloadFriends: () => data.refresh()
6180
+ });
5603
6181
  const callCtl = useCallController(me.userId, (peerId, video, direction) => {
5604
6182
  const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
5605
6183
  dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
@@ -5662,6 +6240,8 @@ function DkApp() {
5662
6240
  React.useEffect(() => {
5663
6241
  if (!activeId)
5664
6242
  return;
6243
+ if (activeId === ASSISTANT_ID)
6244
+ return;
5665
6245
  data.loadThread(activeId, { reset: true });
5666
6246
  dkApi.markRead(activeId).then(data.refresh);
5667
6247
  const iv = setInterval(() => {
@@ -5705,6 +6285,10 @@ function DkApp() {
5705
6285
  const onSend = (text) => {
5706
6286
  if (!activeId || !text || !text.trim())
5707
6287
  return;
6288
+ if (activeId === ASSISTANT_ID) {
6289
+ assistant.handle(text.trim());
6290
+ return;
6291
+ }
5708
6292
  dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
5709
6293
  };
5710
6294
  const peerTakesRtcFile = (id) => {
@@ -5812,7 +6396,7 @@ function DkApp() {
5812
6396
  fontWeight: 600,
5813
6397
  background: tab === n.id ? "var(--accent)" : "transparent",
5814
6398
  color: tab === n.id ? "#fff" : "var(--dim)"
5815
- } }, /* @__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) => {
6399
+ } }, /* @__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) => {
5816
6400
  data.refresh();
5817
6401
  return r;
5818
6402
  }) })), 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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle-ui",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "The Beagle chat UI, minus the machine it runs on — shared by the desktop install and the browser build.",
5
5
  "license": "MIT",
6
6
  "type": "module",