@decentnetwork/beagle 0.1.104 → 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.104";
1
+ window.__DK_UI_VERSION="0.1.107";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -341,6 +341,42 @@ window.__DK_UI_VERSION="0.1.104";
341
341
  channel: "@next",
342
342
  wire: "163"
343
343
  };
344
+ var DK_PAGE = 30;
345
+ function dkMsgKey(m) {
346
+ return m.id || m.ts + ":" + m.dir;
347
+ }
348
+ function dkThreadMsg(m) {
349
+ return {
350
+ id: m.id,
351
+ from: m.dir === "out" ? "me" : "them",
352
+ time: dkClock(m.ts),
353
+ text: m.text,
354
+ file: m.file ? {
355
+ name: m.file.name,
356
+ size: dkFileSize(m.file.size),
357
+ dir: m.dir,
358
+ media: dkFileMediaKind(m.file.name),
359
+ status: m.file.status,
360
+ pct: m.file.status === "sending" && m.file.size ? Math.min(100, (m.file.sent || 0) / m.file.size * 100) : void 0,
361
+ kbps: m.file.kbps
362
+ } : void 0,
363
+ // Pass the real state through. Collapsing everything that was not
364
+ // 'queued' to 'read' meant a message still in flight — or one that
365
+ // never went out at all — rendered as delivered.
366
+ status: m.dir === "out" ? m.status === "queued" || m.status === "sending" || m.status === "failed" || m.status === "sent" ? m.status : "read" : void 0,
367
+ error: m.error,
368
+ // false = went to a live session but nobody acknowledged it (one tick).
369
+ // Dropped here before, so that state could never render.
370
+ confirmed: m.confirmed,
371
+ // Delivery path: "online" = live session, "offline" = express relay.
372
+ // Both directions carry it now; the bubble labels them the same way.
373
+ via: m.via,
374
+ // Brief 30: a validated component form, already whitelisted host-side.
375
+ // Passed through untouched — the renderer treats every string as text,
376
+ // never markup.
377
+ components: m.components
378
+ };
379
+ }
344
380
  function useDaemonData() {
345
381
  const [snap, setSnap] = React.useState({
346
382
  me: DK_ME_FALLBACK,
@@ -386,52 +422,74 @@ window.__DK_UI_VERSION="0.1.104";
386
422
  return () => clearInterval(t);
387
423
  }, [refresh]);
388
424
  const inFlight = React.useRef(/* @__PURE__ */ new Set());
389
- const loadThread = React.useCallback(async (peerId) => {
390
- if (!peerId || inFlight.current.has(peerId))
425
+ const cache = React.useRef({});
426
+ const [earlier, setEarlier] = React.useState({});
427
+ const publish = React.useCallback((peerId) => {
428
+ const c = cache.current[peerId];
429
+ if (!c)
430
+ return;
431
+ const arr = [...c.byId.values()].sort((a, b) => a.ts - b.ts);
432
+ const msgs = arr.map(dkThreadMsg);
433
+ const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
434
+ setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
435
+ setEarlier((e) => e[peerId] === c.more ? e : Object.assign({}, e, { [peerId]: c.more }));
436
+ }, []);
437
+ const loadThread = React.useCallback(async (peerId, opts) => {
438
+ if (!peerId)
439
+ return;
440
+ if (opts && opts.reset)
441
+ delete cache.current[peerId];
442
+ if (inFlight.current.has(peerId))
391
443
  return;
392
444
  inFlight.current.add(peerId);
393
445
  try {
394
- const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId) + "&limit=200");
446
+ const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId) + "&limit=" + DK_PAGE);
395
447
  const arr = d.chats && d.chats[peerId] || [];
396
448
  await prefetchThreadFiles(arr);
397
- const msgs = arr.map((m) => ({
398
- id: m.id,
399
- from: m.dir === "out" ? "me" : "them",
400
- time: dkClock(m.ts),
401
- text: m.text,
402
- file: m.file ? {
403
- name: m.file.name,
404
- size: dkFileSize(m.file.size),
405
- dir: m.dir,
406
- media: dkFileMediaKind(m.file.name),
407
- status: m.file.status,
408
- pct: m.file.status === "sending" && m.file.size ? Math.min(100, (m.file.sent || 0) / m.file.size * 100) : void 0,
409
- kbps: m.file.kbps
410
- } : void 0,
411
- // Pass the real state through. Collapsing everything that was not
412
- // 'queued' to 'read' meant a message still in flight — or one that
413
- // never went out at all — rendered as delivered.
414
- status: m.dir === "out" ? m.status === "queued" || m.status === "sending" || m.status === "failed" || m.status === "sent" ? m.status : "read" : void 0,
415
- error: m.error,
416
- // false = went to a live session but nobody acknowledged it (one tick).
417
- // Dropped here before, so that state could never render.
418
- confirmed: m.confirmed,
419
- // Delivery path: "online" = live session, "offline" = express relay.
420
- // Both directions carry it now; the bubble labels them the same way.
421
- via: m.via,
422
- // Brief 30: a validated component form, already whitelisted host-side.
423
- // Passed through untouched — the renderer treats every string as text,
424
- // never markup.
425
- components: m.components
426
- }));
427
- const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
428
- setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
449
+ let c = cache.current[peerId];
450
+ if (!c)
451
+ c = cache.current[peerId] = { byId: /* @__PURE__ */ new Map(), more: arr.length >= DK_PAGE };
452
+ if (arr.length) {
453
+ const from = arr[0].ts;
454
+ for (const [k, m] of c.byId)
455
+ if (m.ts >= from)
456
+ c.byId.delete(k);
457
+ }
458
+ for (const m of arr)
459
+ c.byId.set(dkMsgKey(m), m);
460
+ publish(peerId);
429
461
  } catch (e) {
430
462
  } finally {
431
463
  inFlight.current.delete(peerId);
432
464
  }
433
- }, []);
434
- return Object.assign({}, snap, { threads, refresh, loadThread });
465
+ }, [publish]);
466
+ const loadEarlier = React.useCallback(async (peerId) => {
467
+ const c = cache.current[peerId];
468
+ if (!c || !c.more || c.loadingEarlier)
469
+ return;
470
+ let oldest = Infinity;
471
+ for (const m of c.byId.values())
472
+ if (m.ts < oldest)
473
+ oldest = m.ts;
474
+ if (!Number.isFinite(oldest))
475
+ return;
476
+ c.loadingEarlier = true;
477
+ try {
478
+ const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId) + "&limit=" + DK_PAGE + "&before=" + oldest);
479
+ const arr = d.chats && d.chats[peerId] || [];
480
+ await prefetchThreadFiles(arr);
481
+ if (cache.current[peerId] !== c)
482
+ return;
483
+ for (const m of arr)
484
+ c.byId.set(dkMsgKey(m), m);
485
+ c.more = arr.length >= DK_PAGE;
486
+ publish(peerId);
487
+ } catch (e) {
488
+ } finally {
489
+ c.loadingEarlier = false;
490
+ }
491
+ }, [publish]);
492
+ return Object.assign({}, snap, { threads, earlier, refresh, loadThread, loadEarlier });
435
493
  }
436
494
  Object.assign(window, {
437
495
  dkHash,
@@ -443,6 +501,558 @@ window.__DK_UI_VERSION="0.1.104";
443
501
  DK_ME_FALLBACK,
444
502
  dkCopy
445
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
+ }
446
1056
  function RequestsBlock({ T, requests, onAct }) {
447
1057
  const [acted, setActed] = React.useState({});
448
1058
  const [failed, setFailed] = React.useState({});
@@ -523,7 +1133,7 @@ window.__DK_UI_VERSION="0.1.104";
523
1133
  whiteSpace: "nowrap",
524
1134
  overflow: "hidden",
525
1135
  textOverflow: "ellipsis"
526
- } }, 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: {
527
1137
  fontSize: 11,
528
1138
  color: "var(--dim)",
529
1139
  whiteSpace: "nowrap",
@@ -579,6 +1189,8 @@ window.__DK_UI_VERSION="0.1.104";
579
1189
  const s = (p.alias || "") + " " + p.userId + " " + p.ip;
580
1190
  return s.toLowerCase().includes(q.toLowerCase());
581
1191
  }).slice().sort((a, b) => {
1192
+ if (!!a.pinned !== !!b.pinned)
1193
+ return a.pinned ? -1 : 1;
582
1194
  const ta = dkActivityTs(a), tb = dkActivityTs(b);
583
1195
  if (ta !== tb)
584
1196
  return tb - ta;
@@ -1244,7 +1856,7 @@ window.__DK_UI_VERSION="0.1.104";
1244
1856
  return null;
1245
1857
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
1246
1858
  }
1247
- 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 }) {
1248
1860
  const mine = m.from === "me";
1249
1861
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
1250
1862
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -1368,6 +1980,8 @@ window.__DK_UI_VERSION="0.1.104";
1368
1980
  {
1369
1981
  src: dkFileUrl(m.file.name),
1370
1982
  alt: m.file.name,
1983
+ loading: "lazy",
1984
+ decoding: "async",
1371
1985
  onError: (e) => {
1372
1986
  const w = e.currentTarget.closest("[data-media]");
1373
1987
  if (w)
@@ -1571,7 +2185,7 @@ window.__DK_UI_VERSION="0.1.104";
1571
2185
  letterSpacing: -0.1,
1572
2186
  wordBreak: "break-word"
1573
2187
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
1574
- })(), 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), (() => {
1575
2189
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
1576
2190
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
1577
2191
  if (!via)
@@ -1616,8 +2230,10 @@ window.__DK_UI_VERSION="0.1.104";
1616
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)" })))
1617
2231
  );
1618
2232
  }
1619
- function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, 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 }) {
1620
2234
  const scrollRef = React.useRef(null);
2235
+ const prependRef = React.useRef(null);
2236
+ const [earlierBusy, setEarlierBusy] = React.useState(false);
1621
2237
  const isGroup = React.useMemo(() => dkThreadIsGroup(threadProp), [threadProp]);
1622
2238
  const followScrollRef = React.useRef(true);
1623
2239
  const fileRef = React.useRef(null);
@@ -1695,9 +2311,30 @@ window.__DK_UI_VERSION="0.1.104";
1695
2311
  followScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
1696
2312
  };
1697
2313
  React.useLayoutEffect(() => {
2314
+ const el = scrollRef.current;
2315
+ const pre = prependRef.current;
2316
+ if (pre && el && el.scrollHeight !== pre.h) {
2317
+ el.scrollTop = pre.top + (el.scrollHeight - pre.h);
2318
+ prependRef.current = null;
2319
+ return;
2320
+ }
1698
2321
  if (followScrollRef.current)
1699
2322
  scrollToBottom();
1700
2323
  }, [peer.id, thread]);
2324
+ React.useEffect(() => {
2325
+ prependRef.current = null;
2326
+ setEarlierBusy(false);
2327
+ }, [peer.id]);
2328
+ const loadEarlier = () => {
2329
+ const el = scrollRef.current;
2330
+ if (!onLoadEarlier || earlierBusy)
2331
+ return;
2332
+ if (el)
2333
+ prependRef.current = { h: el.scrollHeight, top: el.scrollTop };
2334
+ followScrollRef.current = false;
2335
+ setEarlierBusy(true);
2336
+ Promise.resolve(onLoadEarlier()).finally(() => setEarlierBusy(false));
2337
+ };
1701
2338
  const toggleSel = (id) => setSel((s) => {
1702
2339
  const n = new Set(s);
1703
2340
  n.has(id) ? n.delete(id) : n.add(id);
@@ -1898,7 +2535,7 @@ window.__DK_UI_VERSION="0.1.104";
1898
2535
  style: { width: 40, height: 44, flexShrink: 0, border: "none", background: "transparent", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0 }
1899
2536
  },
1900
2537
  /* @__PURE__ */ React.createElement(Icon, { name: "chevronLeft", size: 26, stroke: 2.2, color: "var(--accent)" })
1901
- ), /* @__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(
1902
2539
  Mono,
1903
2540
  {
1904
2541
  size: 11.5,
@@ -1957,15 +2594,17 @@ ${peer.address}`
1957
2594
  fontSize: 12.5,
1958
2595
  color: "var(--text)"
1959
2596
  } }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 13, stroke: 2.2, color: "#d29922" }), /* @__PURE__ */ React.createElement("span", null, T.pendingFriend || "Waiting for them to accept your friend request \u2014 messages will queue and deliver once they do.")),
1960
- /* @__PURE__ */ React.createElement("div", { ref: scrollRef, onScroll: updateFollowScroll, style: { flex: 1, overflow: "auto", padding: phone ? "12px 10px" : "18px 22px", WebkitOverflowScrolling: "touch" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 1200, margin: "0 auto" } }, thread.filter((m) => m.day || !hidden.has(m.id)).map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(
2597
+ /* @__PURE__ */ React.createElement("div", { ref: scrollRef, onScroll: updateFollowScroll, style: { flex: 1, overflow: "auto", padding: phone ? "12px 10px" : "18px 22px", WebkitOverflowScrolling: "touch" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 1200, margin: "0 auto" } }, hasEarlier && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "center", margin: "4px 0 10px" } }, /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: loadEarlier, disabled: earlierBusy }, earlierBusy ? T.loadingEarlier || "Loading\u2026" : T.loadEarlier || "Load earlier messages")), thread.filter((m) => m.day || !hidden.has(m.id)).map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(
1961
2598
  Msg,
1962
2599
  {
1963
2600
  key: m.id || i,
1964
2601
  m,
1965
2602
  peer,
1966
2603
  T,
2604
+ lang,
1967
2605
  isGroup,
1968
2606
  onTheater: setTheater,
2607
+ onAssistantRemove,
1969
2608
  onDelete: (id) => doDelete([id]),
1970
2609
  onCancel: doCancel,
1971
2610
  onRetry: doRetry,
@@ -2117,12 +2756,12 @@ ${peer.address}`
2117
2756
  function ChatEmpty({ T }) {
2118
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));
2119
2758
  }
2120
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, 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 }) {
2121
2760
  const peer = peers.find((p) => p.id === activeId);
2122
2761
  if (phone) {
2123
- return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, 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 });
2124
2763
  }
2125
- 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, 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 }));
2126
2765
  }
2127
2766
  Object.assign(window, { ChatTab });
2128
2767
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -4885,6 +5524,8 @@ ${peer.address}`
4885
5524
  autoAcceptLabel: "Auto-accept friend requests",
4886
5525
  autoAcceptSub: "Off: requests wait in the sidebar for your approval.",
4887
5526
  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.",
5527
+ loadEarlier: "Load earlier messages",
5528
+ loadingEarlier: "Loading\u2026",
4888
5529
  pendingFriend: "Waiting for them to accept your friend request \u2014 messages will queue and deliver once they do.",
4889
5530
  cancelling: "Cancelling\u2026",
4890
5531
  cancelled: "Transfer cancelled",
@@ -5077,6 +5718,8 @@ ${peer.address}`
5077
5718
  autoAcceptLabel: "\u81EA\u52A8\u63A5\u53D7\u597D\u53CB\u8BF7\u6C42",
5078
5719
  autoAcceptSub: "\u5173\u95ED\u540E\uFF0C\u597D\u53CB\u8BF7\u6C42\u4F1A\u7B49\u5F85\u4F60\u5728\u4FA7\u8FB9\u680F\u624B\u52A8\u786E\u8BA4\u3002",
5079
5720
  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",
5721
+ loadEarlier: "\u52A0\u8F7D\u66F4\u65E9\u7684\u6D88\u606F",
5722
+ loadingEarlier: "\u52A0\u8F7D\u4E2D\u2026",
5080
5723
  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",
5081
5724
  cancelling: "\u6B63\u5728\u53D6\u6D88\u2026",
5082
5725
  cancelled: "\u5DF2\u53D6\u6D88\u4F20\u8F93",
@@ -5421,7 +6064,7 @@ ${peer.address}`
5421
6064
  sessionStorage.setItem(key, "1");
5422
6065
  window.location.reload();
5423
6066
  }, [me && me.beagleVer]);
5424
- const peers = data.peers;
6067
+ const peers = React.useMemo(() => [ASSISTANT_PEER].concat(data.peers || []), [data.peers]);
5425
6068
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
5426
6069
  React.useEffect(() => {
5427
6070
  const onHash = () => {
@@ -5485,6 +6128,16 @@ ${peer.address}`
5485
6128
  flash: t.alertFlash !== false,
5486
6129
  muted: mutedIds
5487
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
+ });
5488
6141
  const callCtl = useCallController(me.userId, (peerId, video, direction) => {
5489
6142
  const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
5490
6143
  dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
@@ -5547,7 +6200,9 @@ ${peer.address}`
5547
6200
  React.useEffect(() => {
5548
6201
  if (!activeId)
5549
6202
  return;
5550
- data.loadThread(activeId);
6203
+ if (activeId === ASSISTANT_ID)
6204
+ return;
6205
+ data.loadThread(activeId, { reset: true });
5551
6206
  dkApi.markRead(activeId).then(data.refresh);
5552
6207
  const iv = setInterval(() => {
5553
6208
  data.loadThread(activeId);
@@ -5590,6 +6245,10 @@ ${peer.address}`
5590
6245
  const onSend = (text) => {
5591
6246
  if (!activeId || !text || !text.trim())
5592
6247
  return;
6248
+ if (activeId === ASSISTANT_ID) {
6249
+ assistant.handle(text.trim());
6250
+ return;
6251
+ }
5593
6252
  dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
5594
6253
  };
5595
6254
  const peerTakesRtcFile = (id) => {
@@ -5697,7 +6356,7 @@ ${peer.address}`
5697
6356
  fontWeight: 600,
5698
6357
  background: tab === n.id ? "var(--accent)" : "transparent",
5699
6358
  color: tab === n.id ? "#fff" : "var(--dim)"
5700
- } }, /* @__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), 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) => {
5701
6360
  data.refresh();
5702
6361
  return r;
5703
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.104",
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.19",
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",